diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 123c83b70..2adcd92a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,48 @@ If you need to sell a new type of product you will implement a Service type modu Other modules extend the whole FOSSBilling API with any functionality needed. Check existing modules to get an idea of what is already shipped with the default structure of FOSSBilling. +### Typed module events + +FOSSBilling modules use typed events for extension points. Event classes extend `FOSSBilling\Events\Event`, and each module keeps its event classes in `src/modules//Event/`. The shared base event is in `src/library/FOSSBilling/Events/Event.php`. + +Define an event in the owning module's `Event/` directory and dispatch it from the code that owns the operation: + +```php +namespace Box\Mod\Example\Event; + +use FOSSBilling\Events\Event; + +final class BeforeExampleOperationEvent extends Event +{ + public function __construct(public readonly int $entityId) + { + } +} +``` + +```php +use Box\Mod\Example\Event\BeforeExampleOperationEvent; + +$this->di['event_dispatcher']->dispatch(new BeforeExampleOperationEvent($entityId)); +``` + +A module can listen from a public instance method on its `Service` class. Add Symfony's `#[AsEventListener]` attribute and type the first parameter as the event class. Listeners on active modules are registered when the dispatcher initializes. + +```php +use Box\Mod\Support\Event\AfterTicketOpenedEvent; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +#[AsEventListener(priority: 10)] +public function notifyTicketOpened(AfterTicketOpenedEvent $event): void +{ + // Handle the opened ticket using the event's documented data. +} +``` + +Higher priority listeners run first. Event propagation follows Symfony's event behavior, including `stopPropagation()`. Listeners run synchronously as part of dispatch. + +Treat each event class as the contract for its extension point. Prefer readonly constructor properties for context listeners may inspect. Keep mutable data private and expose only the getters and setters listeners are meant to use. Do not include credentials or unrelated request data in an event payload. Name event classes for the operation and timing, such as `Before...Event` or `After...Event`. + ## How can I contribute? There are a lot of different ways that you can get involved in the FOSSBilling project. Let's take a look at some of the main ones: diff --git a/composer.json b/composer.json index 33bccda6f..9ba2240f4 100644 --- a/composer.json +++ b/composer.json @@ -68,6 +68,7 @@ "stripe/stripe-php": "^21.0.0", "symfony/cache": "^7.4.1", "symfony/console": "^7.4.1", + "symfony/event-dispatcher": "^7.4", "symfony/filesystem": "^7.4", "symfony/finder": "^7.4", "symfony/html-sanitizer": "^7.4", diff --git a/composer.lock b/composer.lock index c5242c36b..540f6fc82 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "993c4fade814e1005b343443e7637c12", + "content-hash": "d2fa7f56bd094c9c59af05a9fbb588cb", "packages": [ { "name": "dflydev/dot-access-data", diff --git a/src/di.php b/src/di.php index b39611ee8..305bd287b 100644 --- a/src/di.php +++ b/src/di.php @@ -185,18 +185,11 @@ $di['mod_config'] = $di->protect(fn ($name) => $di['mod']($name)->getConfig()); $di['cookie_queue'] = fn (): FOSSBilling\Http\CookieQueue => new FOSSBilling\Http\CookieQueue(); -/* - * - * @param void - * - * @return \Box_EventManager - */ -$di['events_manager'] = function () use ($di) { - $service = new Box_EventManager(); - $service->setDi($di); - - return $service; -}; +$di['event_dispatcher'] = fn (): FOSSBilling\Events\EventDispatcher => new FOSSBilling\Events\EventDispatcher( + fn (): array => $di['mod_service']('extension')->getCoreAndActiveModules(), + fn (string $module): object => $di['mod_service']($module), + $di['logger']->withChannel('event'), +); /* * Creates a new session, applying specified security rules depending on the config.php settings. diff --git a/src/install/sql/content.sql b/src/install/sql/content.sql index cd6f5b085..03973831a 100644 --- a/src/install/sql/content.sql +++ b/src/install/sql/content.sql @@ -217,7 +217,7 @@ INSERT INTO `post` (`id`, `admin_id`, `title`, `description`, `content`, `slug`, VALUES (1,1,'FOSSBilling needs your donations','Donations are crucial for the success and sustainability of FOSSBilling and other open-source projects. We need your help.','Donations are crucial for the success and sustainability of FOSSBilling and other open-source projects. Without financial support from users and the broader community, it is difficult for these projects to continue to grow and evolve.\nDonations help to cover the costs of development and maintenance. Developing and maintaining software requires time and resources, and without financial support, it can be challenging for open-source projects to sustain their efforts.\n\nThe FOSSBilling project has expenses such as hosting, hardware, software, and other costs associated with developing and maintaining FOSSBilling and other open-source projects. Your donations also help cover some of the maintainers'' expenses.\nClick [here](https://fossbilling.org/donate) to donate to the FOSSBilling project.','fossbilling-needs-your-donations','active',NULL,NULL,NULL,NULL,NULL,NOW(),NOW()), (2,1,'Check out great features of FOSSBilling','FOSSBilling supports automated billing, invoicing, product provisioning', '* Supports automated billing, invoicing, product provisioning\n* Automatically create accounts as soon as the payment is received, suspend when account becomes overdue, terminate when a specified amount of time passes.\n* FOSSBilling is perfectly created to sell shared and reseller hosting accounts, software licenses and downloadable products.\n* Integrated helpdesk, knowledgebase, news and announcements system.\n','great-features-of-fossbilling','active',NULL,NULL,NULL,NULL,NULL,NOW(),NOW()), - (3,1,'FOSSBilling is customizable','You can create your own simple or advanced hooks on FOSSBilling events.','* You can create your own simple or advanced hooks on FOSSBilling events. For example, send notification via sms when new client signs up.\n* Create custom theme for your client interface\n','fossbilling-is-customizable','active',NULL,NULL,NULL,NULL,NULL,NOW(),NOW()); + (3,1,'FOSSBilling is customizable','You can extend FOSSBilling with typed event listeners.','* You can extend FOSSBilling with typed event listeners. For example, send an SMS notification when a new client signs up.\n* Create custom theme for your client interface\n','fossbilling-is-customizable','active',NULL,NULL,NULL,NULL,NULL,NOW(),NOW()); /*!40000 ALTER TABLE `post` ENABLE KEYS */; UNLOCK TABLES; diff --git a/src/library/Box/Event.php b/src/library/Box/Event.php deleted file mode 100644 index d333f8efb..000000000 --- a/src/library/Box/Event.php +++ /dev/null @@ -1,166 +0,0 @@ -di = $di; - } - - public function getDi(): ?Pimple\Container - { - return $this->di; - } - - /** - * Returns the subject. - * - * @return mixed The subject - */ - public function getSubject() - { - return $this->subject; - } - - public function getApiAdmin() - { - return $this->api_admin; - } - - public function getApiGuest() - { - return $this->api_guest; - } - - /** - * Returns the event name. - * - * @return string The event name - */ - public function getName() - { - return $this->name; - } - - /** - * Sets the return value for this event. - * - * @param mixed $value The return value - */ - public function setReturnValue(mixed $value): void - { - $this->value = $value; - } - - /** - * Returns the return value. - * - * @return mixed The return value - */ - public function getReturnValue() - { - return $this->value; - } - - /** - * Sets the processed flag. - * - * @param bool $processed The processed flag value - */ - public function setProcessed($processed): void - { - $this->processed = (bool) $processed; - } - - /** - * Returns whether the event has been processed by a listener or not. - * - * @return bool true if the event has been processed, false otherwise - */ - public function isProcessed() - { - return $this->processed; - } - - /** - * Returns the event parameters. - * - * @return array The event parameters - */ - public function getParameters() - { - return $this->parameters; - } - - /** - * Returns true if the parameter exists (implements the ArrayAccess interface). - * - * @param string $name The parameter name - * - * @return bool true if the parameter exists, false otherwise - */ - public function offsetExists(mixed $name): bool - { - return array_key_exists($name, $this->parameters); - } - - /** - * Returns a parameter value (implements the ArrayAccess interface). - * - * @param string $name The parameter name - * - * @return mixed The parameter value - */ - public function offsetGet(mixed $name): mixed - { - if (!array_key_exists($name, $this->parameters)) { - throw new InvalidArgumentException("The event '{$this->name}' has no '{$name}' parameter."); - } - - return $this->parameters[$name]; - } - - /** - * Sets a parameter (implements the ArrayAccess interface). - * - * @param string $name The parameter name - * @param mixed $value The parameter value - */ - public function offsetSet(mixed $name, mixed $value): void - { - $this->parameters[$name] = $value; - } - - /** - * Removes a parameter (implements the ArrayAccess interface). - * (doesn't actually remove anything). - * - * @param string $name The parameter name - */ - public function offsetUnset(mixed $name): void - { - } -} diff --git a/src/library/Box/EventDispatcher.php b/src/library/Box/EventDispatcher.php deleted file mode 100644 index d5a508a93..000000000 --- a/src/library/Box/EventDispatcher.php +++ /dev/null @@ -1,92 +0,0 @@ -listeners[$name] ??= []; - - $this->listeners[$name][] = $listener; - } - - /** - * Notifies all listeners of a given event. - * - * @param Box_Event $event A Box_Event instance - * - * @return Box_Event The Box_Event instance - */ - public function notify(Box_Event $event) - { - foreach ($this->getListeners($event->getName()) as $listener) { - call_user_func($listener, $event); - } - - return $event; - } - - /** - * Filters a value by calling all listeners of a given event. - * - * @param Box_Event $event A Box_Event instance - * @param mixed $value The value to be filtered - * - * @return Box_Event The Box_Event instance - */ - public function filter(Box_Event $event, mixed $value) - { - foreach ($this->getListeners($event->getName()) as $listener) { - $value = call_user_func_array($listener, [$event, $value]); - } - - $event->setReturnValue($value); - - return $event; - } - - /** - * Returns true if the given event name has some listeners. - * - * @param string $name The event name - * - * @return bool true if some listeners are connected, false otherwise - */ - public function hasListeners($name): bool - { - $this->listeners[$name] ??= []; - - return (bool) FOSSBilling\Tools::safeCount($this->listeners[$name]); - } - - /** - * Returns all listeners associated with a given event name. - * - * @param string $name The event name - * - * @return array An array of listeners - */ - public function getListeners($name) - { - if (!isset($this->listeners[$name])) { - return []; - } - - return $this->listeners[$name]; - } -} diff --git a/src/library/Box/EventManager.php b/src/library/Box/EventManager.php deleted file mode 100644 index 19708b5f6..000000000 --- a/src/library/Box/EventManager.php +++ /dev/null @@ -1,112 +0,0 @@ ->|null */ - private ?array $databaseListeners = null; - - public const GLOBAL_LISTENER_NAME = 'onEveryEvent'; - - public function setDi(Pimple\Container $di): void - { - $this->di = $di; - } - - public function getDi(): ?Pimple\Container - { - return $this->di; - } - - public function fire($data) - { - if (!isset($data['event']) || empty($data['event'])) { - if ($this->di !== null && isset($this->di['logger'])) { - $this->di['logger']->warning('Invoked event call without providing event name'); - } - - return false; - } - - $event = $data['event']; - $subject = $data['subject'] ?? null; - $params = $data['params'] ?? null; - - $eventContext = ['event' => $event]; - if (is_array($params)) { - $eventContext['parameters'] = $params; - } - $this->di['logger']->withChannel('event')->debug('Fired event: {event}', $eventContext); - - $e = new Box_Event($subject, $event, $params); - $e->setDi($this->di); - $disp = new Box_EventDispatcher(); - - $eventName = $e->getName(); - - $this->connectDatabaseHooks($disp, $eventName); - $this->connectDatabaseHooks($disp, self::GLOBAL_LISTENER_NAME, $eventName); // Also connect global listeners (onEveryEvent) to the fired event - - $disp->notify($e); - - return $e->getReturnValue(); - } - - public function clearListenerCache(): void - { - $this->databaseListeners = null; - } - - private function connectDatabaseHooks(Box_EventDispatcher $disp, string $event, ?string $dispatchEventName = null): void - { - $listeners = $this->getDatabaseListeners()[$event] ?? []; - - foreach ($listeners as $listener) { - $mod = $listener['rel_id']; - $listenerEvent = $listener['meta_value']; - $dispatchEvent = $dispatchEventName ?? $listenerEvent; - - try { - $service = $this->di['mod_service']($mod); - - if (method_exists($service, $listenerEvent)) { - $disp->connect($dispatchEvent, [$service::class, $listenerEvent]); - } - } catch (Exception $e) { - $this->di['logger']->withChannel('event')->error($e->getMessage()); - } - } - } - - /** @return array> */ - private function getDatabaseListeners(): array - { - if ($this->databaseListeners !== null) { - return $this->databaseListeners; - } - - $sql = "SELECT rel_id, meta_value - FROM extension_meta - WHERE extension = 'mod_hook' - AND rel_type = 'mod' - AND meta_key = 'listener' - "; - $listeners = $this->di['em']->getConnection()->fetchAllAssociative($sql); - $this->databaseListeners = []; - - foreach ($listeners as $listener) { - $this->databaseListeners[(string) $listener['meta_value']][] = $listener; - } - - return $this->databaseListeners; - } -} diff --git a/src/library/FOSSBilling/Events/Event.php b/src/library/FOSSBilling/Events/Event.php new file mode 100644 index 000000000..c9539534b --- /dev/null +++ b/src/library/FOSSBilling/Events/Event.php @@ -0,0 +1,16 @@ + $activeModules + * @param \Closure(string): object $moduleService + */ + public function __construct( + private readonly \Closure $activeModules, + private readonly \Closure $moduleService, + private readonly ?LoggerInterface $logger = null, + ) { + $this->dispatcher = new SymfonyEventDispatcher(); + } + + public function dispatch(Event $event): Event + { + $this->initialize(); + $this->logger?->debug('Dispatching event {event}', ['event' => $event::class]); + + return $this->dispatcher->dispatch($event); + } + + /** + * Rebuild listener registrations after a module is activated or deactivated. + */ + public function refresh(): void + { + $this->dispatcher = new SymfonyEventDispatcher(); + $this->initialized = false; + } + + private function initialize(): void + { + if ($this->initialized) { + return; + } + + // Set this first so a listener's service setup cannot recursively scan modules. + $this->initialized = true; + + try { + $services = []; + foreach (array_unique(($this->activeModules)()) as $moduleName) { + $className = 'Box\\Mod\\' . ucfirst($moduleName) . '\\Service'; + if (!class_exists($className)) { + continue; + } + + $reflection = new \ReflectionClass($className); + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + foreach ($method->getAttributes(AsEventListener::class) as $attribute) { + $listener = $attribute->newInstance(); + $parameterType = $this->eventType($method); + $eventName = $listener->event ?? $parameterType; + if ($eventName === null || !is_a($eventName, Event::class, true) || $parameterType === null || !is_a($eventName, $parameterType, true)) { + throw new \LogicException(sprintf('Listener %s::%s must accept its FOSSBilling event class.', $className, $method->getName())); + } + + if ($listener->dispatcher !== null || ($listener->method !== null && $listener->method !== $method->getName())) { + throw new \LogicException(sprintf('Listener %s::%s uses unsupported AsEventListener options.', $className, $method->getName())); + } + + $service = $services[$moduleName] ??= ($this->moduleService)($moduleName); + $this->dispatcher->addListener($eventName, [$service, $method->getName()], $listener->priority); + } + } + } + } catch (\Throwable $error) { + $this->refresh(); + + throw $error; + } + } + + private function eventType(\ReflectionMethod $method): ?string + { + $type = $method->getParameters()[0]->getType() ?? null; + + return $type instanceof \ReflectionNamedType && !$type->isBuiltin() ? $type->getName() : null; + } +} diff --git a/src/library/FOSSBilling/ExtensionManager.php b/src/library/FOSSBilling/ExtensionManager.php index 95d690e4b..15646ce6c 100644 --- a/src/library/FOSSBilling/ExtensionManager.php +++ b/src/library/FOSSBilling/ExtensionManager.php @@ -22,7 +22,6 @@ class ExtensionManager implements InjectionAwareInterface final public const string TYPE_PG = 'payment-gateway'; final public const string TYPE_SM = 'server-manager'; final public const string TYPE_DR = 'domain-registrar'; - final public const string TYPE_HOOK = 'hook'; final public const string TYPE_TRANSLATION = 'translation'; private string $apiUrl = 'https://api.fossbilling.net/extensions/v1/'; diff --git a/src/library/FOSSBilling/Module.php b/src/library/FOSSBilling/Module.php index e423d41c2..7a56bdcee 100644 --- a/src/library/FOSSBilling/Module.php +++ b/src/library/FOSSBilling/Module.php @@ -28,7 +28,7 @@ class Module implements InjectionAwareInterface public const CORE_MODULES = ['api', 'activity', 'antispam', 'cart', 'client', 'cron', 'currency', 'email', 'extension', - 'hook', 'index', 'invoice', 'order', + 'index', 'invoice', 'order', 'page', 'product', 'profile', 'security', 'servicecustom', 'servicedomain', 'servicedownloadable', 'servicehosting', 'servicelicense', 'staff', 'stats', diff --git a/src/library/FOSSBilling/SentryHelper.php b/src/library/FOSSBilling/SentryHelper.php index a81fc18e3..17b9ba20a 100644 --- a/src/library/FOSSBilling/SentryHelper.php +++ b/src/library/FOSSBilling/SentryHelper.php @@ -51,7 +51,6 @@ class SentryHelper 'embed', 'extension', 'formbuilder', - 'hook', 'index', 'invoice', 'massmailer', diff --git a/src/library/FOSSBilling/UpdatePatcher.php b/src/library/FOSSBilling/UpdatePatcher.php index 074fd01ae..d4f574bf9 100644 --- a/src/library/FOSSBilling/UpdatePatcher.php +++ b/src/library/FOSSBilling/UpdatePatcher.php @@ -193,10 +193,6 @@ class UpdatePatcher implements InjectionAwareInterface call_user_func($patch); $this->setPatchLevel($patchLevel); } - - if ($patches !== [] && $this->di !== null && $this->di->offsetExists('events_manager')) { - $this->di['events_manager']->clearListenerCache(); - } } // Portable (plain UPDATE ... WHERE, no MySQL-specific syntax) and idempotent, so it @@ -206,6 +202,10 @@ class UpdatePatcher implements InjectionAwareInterface // forever. $this->migrateThemePackageLayout(); + // Retired hook packages and listener registrations have no runtime consumer. Remove + // their records on every driver so old installs do not retain invisible extensions. + $this->removeRetiredHookData(); + // Same treatment for the debit-note settings rows content.sql seeds for fresh installs: // plain check-then-insert SQL, idempotent, so every platform gets them even though no // MySQL-only patch can run there. @@ -1529,9 +1529,6 @@ class UpdatePatcher implements InjectionAwareInterface $this->executeSql("DELETE FROM extension_meta WHERE extension = 'mod_hook' AND rel_type = 'mod' AND rel_id = 'spamchecker' AND meta_key = 'listener'"); - $hookService = $this->di['mod_service']('hook'); - $hookService->batchConnect('antispam'); - $this->executeSql("DELETE FROM extension_meta WHERE extension = 'mod_spamchecker' AND meta_key = 'config'"); $spamcheckerExt = $extService->getExtensionRepository()->findOneByTypeAndName('mod', 'spamchecker'); @@ -3189,6 +3186,13 @@ class UpdatePatcher implements InjectionAwareInterface return ['invoices' => $repairedInvoices, 'notifications' => $repairedNotes]; } + /** Remove obsolete listener registrations and standalone hook-package records. */ + private function removeRetiredHookData(): void + { + $this->executeSql("DELETE FROM extension_meta WHERE extension = 'mod_hook' AND meta_key = 'listener'"); + $this->executeSql("DELETE FROM extension WHERE type = 'hook'"); + } + /** * Bundles the shipped themes into one package: admin_default -> default/admin, * huraga -> default/client. Third-party themes are untouched. diff --git a/src/modules/Activity/Service.php b/src/modules/Activity/Service.php index 22c051267..bd1b8c082 100644 --- a/src/modules/Activity/Service.php +++ b/src/modules/Activity/Service.php @@ -17,10 +17,14 @@ use Box\Mod\Activity\Entity\ActivitySystem; use Box\Mod\Activity\Repository\ActivityClientHistoryRepository; use Box\Mod\Activity\Repository\ActivitySystemRepository; use Box\Mod\Client\Entity\Client; +use Box\Mod\Client\Event\AfterClientLoginEvent; +use Box\Mod\Cron\Event\BeforeAdminCronRunEvent; +use Box\Mod\Staff\Event\AfterAdminLoginEvent; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Types\Types; use FOSSBilling\InjectionAwareInterface; use FOSSBilling\SortOptions; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; class Service implements InjectionAwareInterface { @@ -58,11 +62,6 @@ class Service implements InjectionAwareInterface return $this->di['dbal']; } - private static function getDbalFromDi(\Pimple\Container $di): Connection - { - return $di['dbal']; - } - public function logEvent($data): void { $extensionService = $this->di['mod_service']('extension'); @@ -81,42 +80,38 @@ class Service implements InjectionAwareInterface $this->di['em']->flush(); } - public static function onAfterClientLogin(\Box_Event $event): void + #[AsEventListener] + public function recordClientLogin(AfterClientLoginEvent $event): void { - $params = $event->getParameters(); - $di = $event->getDi(); - - $extensionService = $di['mod_service']('extension'); - $ip = $extensionService->isExtensionActive('mod', 'demo') ? null : $params['ip']; + $extensionService = $this->di['mod_service']('extension'); + $ip = $extensionService->isExtensionActive('mod', 'demo') ? null : $event->ip; $history = (new ActivityClientHistory()) - ->setClientId((int) $params['id']) + ->setClientId($event->clientId) ->setIp($ip); - $di['em']->persist($history); - $di['em']->flush(); + $this->di['em']->persist($history); + $this->di['em']->flush(); } - public static function onAfterAdminLogin(\Box_Event $event): void + #[AsEventListener] + public function recordAdminLogin(AfterAdminLoginEvent $event): void { - $params = $event->getParameters(); - $di = $event->getDi(); - - $extensionService = $di['mod_service']('extension'); - $ip = $extensionService->isExtensionActive('mod', 'demo') ? null : $params['ip']; + $extensionService = $this->di['mod_service']('extension'); + $ip = $extensionService->isExtensionActive('mod', 'demo') ? null : $event->ip; $history = (new ActivityAdminHistory()) - ->setAdminId((int) $params['id']) + ->setAdminId($event->adminId) ->setIp($ip); - $di['em']->persist($history); - $di['em']->flush(); + $this->di['em']->persist($history); + $this->di['em']->flush(); } - public static function onBeforeAdminCronRun(\Box_Event $event): void + #[AsEventListener] + public function cleanupOldActivity(BeforeAdminCronRunEvent $event): void { - $di = $event->getDi(); - $config = $di['mod_service']('extension')->getConfig('mod_activity'); + $config = $this->di['mod_service']('extension')->getConfig('mod_activity'); $retention = intval($config['max_age'] ?? 90); $emailRetention = intval($config['email_max_age'] ?? 0); @@ -127,7 +122,7 @@ class Service implements InjectionAwareInterface $ageInSeconds = $retention * 86_400; $emailAgeInSeconds = $emailRetention * 86_400; - $dbal = self::getDbalFromDi($di); + $dbal = $this->getDbal(); try { if ($retention !== 0) { @@ -143,7 +138,7 @@ class Service implements InjectionAwareInterface ]); } } catch (\Exception $e) { - $di['logger']->error($e->getMessage()); + $this->di['logger']->error($e->getMessage()); } } diff --git a/src/modules/Activity/tests/Unit/ServiceTest.php b/src/modules/Activity/tests/Unit/ServiceTest.php index d999729a8..4d0407dd2 100644 --- a/src/modules/Activity/tests/Unit/ServiceTest.php +++ b/src/modules/Activity/tests/Unit/ServiceTest.php @@ -16,6 +16,9 @@ use Box\Mod\Activity\Entity\ActivitySystem; use Box\Mod\Activity\Repository\ActivityClientHistoryRepository; use Box\Mod\Activity\Repository\ActivitySystemRepository; use Box\Mod\Client\Entity\Client; +use Box\Mod\Client\Event\AfterClientLoginEvent; +use Box\Mod\Cron\Event\BeforeAdminCronRunEvent; +use Box\Mod\Staff\Event\AfterAdminLoginEvent; use Doctrine\ORM\EntityManagerInterface; use function Tests\Helpers\container; @@ -105,13 +108,13 @@ test('log event persists a system activity entity', function (): void { ->and($persisted->getMessage())->toBe('Test event'); }); -test('login events persist history entities', function (string $method, string $entityClass, string $idGetter): void { +test('typed client login event persists history', function (): void { $persisted = null; $entityManager = Mockery::mock(EntityManagerInterface::class); - $entityManager->shouldReceive('persist')->once()->withArgs(function (object $history) use (&$persisted, $entityClass): bool { + $entityManager->shouldReceive('persist')->once()->withArgs(function (ActivityClientHistory $history) use (&$persisted): bool { $persisted = $history; - return $history instanceof $entityClass; + return true; }); $entityManager->shouldReceive('flush')->once(); @@ -122,18 +125,40 @@ test('login events persist history entities', function (string $method, string $ $di['em'] = $entityManager; $di['mod_service'] = $di->protect(fn (): object => $extensionService); - $event = new Box_Event(null, $method, ['id' => 7, 'ip' => '192.0.2.1']); - $event->setDi($di); + $service = new Box\Mod\Activity\Service(); + $service->setDi($di); + $service->recordClientLogin(new AfterClientLoginEvent(7, '192.0.2.1')); - Box\Mod\Activity\Service::{$method}($event); - - expect($persisted)->toBeInstanceOf($entityClass) - ->and($persisted->{$idGetter}())->toBe(7) + expect($persisted)->toBeInstanceOf(ActivityClientHistory::class) + ->and($persisted->getClientId())->toBe(7) ->and($persisted->getIp())->toBe('192.0.2.1'); -})->with([ - ['onAfterClientLogin', ActivityClientHistory::class, 'getClientId'], - ['onAfterAdminLogin', ActivityAdminHistory::class, 'getAdminId'], -]); +}); + +test('typed admin login event persists history', function (): void { + $persisted = null; + $entityManager = Mockery::mock(EntityManagerInterface::class); + $entityManager->shouldReceive('persist')->once()->withArgs(function (ActivityAdminHistory $history) use (&$persisted): bool { + $persisted = $history; + + return true; + }); + $entityManager->shouldReceive('flush')->once(); + + $extensionService = Mockery::mock(Box\Mod\Extension\Service::class); + $extensionService->shouldReceive('isExtensionActive')->once()->with('mod', 'demo')->andReturnFalse(); + + $di = container(); + $di['em'] = $entityManager; + $di['mod_service'] = $di->protect(fn (): object => $extensionService); + + $service = new Box\Mod\Activity\Service(); + $service->setDi($di); + $service->recordAdminLogin(new AfterAdminLoginEvent(7, '192.0.2.1')); + + expect($persisted)->toBeInstanceOf(ActivityAdminHistory::class) + ->and($persisted->getAdminId())->toBe(7) + ->and($persisted->getIp())->toBe('192.0.2.1'); +}); test('logs retention failures through the application logger', function (): void { $dbal = Mockery::mock(Doctrine\DBAL\Connection::class); @@ -153,10 +178,9 @@ test('logs retention failures through the application logger', function (): void $di['logger'] = $logger; $di['mod_service'] = $di->protect(fn (string $name): object => $extensionService); - $event = new Box_Event(null, 'onBeforeAdminCronRun'); - $event->setDi($di); - - Box\Mod\Activity\Service::onBeforeAdminCronRun($event); + $service = new Box\Mod\Activity\Service(); + $service->setDi($di); + $service->cleanupOldActivity(new BeforeAdminCronRunEvent()); expect($logger->calls)->toContain([ 'method' => 'error', diff --git a/src/modules/Antispam/Service.php b/src/modules/Antispam/Service.php index 882a5f767..86be344cb 100644 --- a/src/modules/Antispam/Service.php +++ b/src/modules/Antispam/Service.php @@ -11,9 +11,16 @@ declare(strict_types=1); namespace Box\Mod\Antispam; +use Box\Mod\Client\Event\BeforeAdminClientUpdateEvent; +use Box\Mod\Client\Event\BeforeClientLoginEvent; +use Box\Mod\Client\Event\BeforeClientSignUpEvent; +use Box\Mod\Profile\Event\BeforeClientProfileUpdateEvent; +use Box\Mod\Staff\Event\BeforeAdminLoginEvent; +use Box\Mod\Support\Event\BeforeGuestTicketCreateEvent; use EmailChecker\Adapter; use EmailChecker\Utilities; use FOSSBilling\InjectionAwareInterface; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; use Symfony\Contracts\Cache\ItemInterface; @@ -58,72 +65,64 @@ class Service implements InjectionAwareInterface ]; } - public static function onBeforeClientSignUp(\Box_Event $event): void + #[AsEventListener] + public function onBeforeClientSignUp(BeforeClientSignUpEvent $event): void { // Client\Api\Guest::create() already verifies the CAPTCHA before firing - // this event, so isSpam() below must not check it again: CAPTCHA - // tokens are single-use, and a second siteverify call would fail and - // reject every genuine signup. - $di = $event->getDi(); - $antispamService = $di['mod_service']('Antispam'); - $antispamService->isBlockedIp($event); - $antispamService->isSpam($event); - $antispamService->isTemp($event); - $antispamService->checkHoneypot($event); + // this event. CAPTCHA tokens are single-use, so checking it again here + // would reject every genuine signup. + $di = $this->di ?? throw new \LogicException('Antispam service must be initialized before handling events.'); + $this->checkBlockedIp($di); + $this->checkSpam($di, $event->input); + $this->checkTempEmail($di, $event->input); + $this->checkHoneypot($di, $event->input); } - public static function onBeforeClientOpenTicket(\Box_Event $event): void + #[AsEventListener] + public function onBeforeGuestTicketCreate(BeforeGuestTicketCreateEvent $event): void { - if ($event->getParameters()['author_role'] !== 'guest') { + if (($event->input['author_role'] ?? null) !== 'guest') { return; } - $di = $event->getDi(); - $antispamService = $di['mod_service']('Antispam'); - $antispamService->isBlockedIp($event); - $antispamService->checkCaptcha($event->getParameters()); - $antispamService->isSpam($event); - $antispamService->isTemp($event); + $di = $this->di ?? throw new \LogicException('Antispam service must be initialized before handling events.'); + + $this->checkBlockedIp($di); + $this->checkCaptcha($event->input); + $this->checkSpam($di, $event->input); + $this->checkTempEmail($di, $event->input); } - public static function onBeforeClientProfileUpdate(\Box_Event $event): void + #[AsEventListener] + public function onBeforeClientProfileUpdate(BeforeClientProfileUpdateEvent $event): void { - $di = $event->getDi(); - $antispamService = $di['mod_service']('Antispam'); - $antispamService->isBlockedIp($event); + $di = $this->di ?? throw new \LogicException('Antispam service must be initialized before handling events.'); + $this->checkBlockedIp($di); } - public static function onBeforeAdminClientUpdate(\Box_Event $event): void + #[AsEventListener] + public function onBeforeAdminClientUpdate(BeforeAdminClientUpdateEvent $event): void { - $di = $event->getDi(); - $antispamService = $di['mod_service']('Antispam'); - $antispamService->isBlockedIp($event); + $di = $this->di ?? throw new \LogicException('Antispam service must be initialized before handling events.'); + $this->checkBlockedIp($di); } - public static function onBeforeClientUpdate(\Box_Event $event): void + #[AsEventListener] + public function onBeforeClientLogin(BeforeClientLoginEvent $event): void { - $di = $event->getDi(); - $antispamService = $di['mod_service']('Antispam'); - $antispamService->isBlockedIp($event); + $di = $this->di ?? throw new \LogicException('Antispam service must be initialized before handling events.'); + $this->checkBlockedIp($di); } - public static function onBeforeClientLogin(\Box_Event $event): void + #[AsEventListener] + public function onBeforeAdminLogin(BeforeAdminLoginEvent $event): void { - $di = $event->getDi(); - $antispamService = $di['mod_service']('Antispam'); - $antispamService->isBlockedIp($event); + $di = $this->di ?? throw new \LogicException('Antispam service must be initialized before handling events.'); + $this->checkBlockedIp($di); } - public static function onBeforeAdminLogin(\Box_Event $event): void + private function checkBlockedIp(\Pimple\Container $di): void { - $di = $event->getDi(); - $antispamService = $di['mod_service']('Antispam'); - $antispamService->isBlockedIp($event); - } - - public function isBlockedIp(\Box_Event $event): void - { - $di = $event->getDi(); $config = $di['mod_config']('Antispam'); if (isset($config['block_ips']) && $config['block_ips'] && isset($config['blocked_ips'])) { $blocked_ips = explode(PHP_EOL, $config['blocked_ips']); @@ -134,11 +133,9 @@ class Service implements InjectionAwareInterface } } - public function isSpam(\Box_Event $event): void + /** @param array $params */ + private function checkSpam(\Pimple\Container $di, array $params): void { - $di = $event->getDi(); - $params = $event->getParameters(); - $data = [ 'ip' => $params['ip'] ?? null, 'email' => $params['email'] ?? null, @@ -252,33 +249,31 @@ class Service implements InjectionAwareInterface } } - public function isTemp(\Box_Event $event): void + /** @param array $params */ + private function checkTempEmail(\Pimple\Container $di, array $params): void { - $di = $event->getDi(); $config = $di['mod_config']('Antispam'); $check = $config['check_temp_emails'] ?? true; if ($check) { - $antispamService = $di['mod_service']('Antispam'); - $params = $event->getParameters(); $email = $params['email'] ?? ''; + $antispamService = $di['mod_service']('Antispam'); $antispamService->isATempEmail($email, true); } } - public function checkHoneypot(\Box_Event $event): void + /** @param array $params */ + private function checkHoneypot(\Pimple\Container $di, array $params): void { - $di = $event->getDi(); $config = $di['mod_config']('Antispam'); $enabled = $config['honeypot_enabled'] ?? true; if ($enabled) { - $params = $event->getParameters(); $honeypotField = $config['honeypot_field'] ?? 'bio'; if (!empty($params[$honeypotField])) { - $this->di['logger']->info('Potential spam registration blocked. Reason: honeypot field was not empty.'); + $di['logger']->info('Potential spam registration blocked. Reason: honeypot field was not empty.'); throw new \FOSSBilling\InformationException('Registration failed.'); } diff --git a/src/modules/Antispam/tests/Unit/ServiceTest.php b/src/modules/Antispam/tests/Unit/ServiceTest.php index 39df0e0bf..4bf0bfa4b 100644 --- a/src/modules/Antispam/tests/Unit/ServiceTest.php +++ b/src/modules/Antispam/tests/Unit/ServiceTest.php @@ -10,6 +10,12 @@ declare(strict_types=1); +use Box\Mod\Client\Event\BeforeAdminClientUpdateEvent; +use Box\Mod\Client\Event\BeforeClientLoginEvent; +use Box\Mod\Client\Event\BeforeClientSignUpEvent; +use Box\Mod\Profile\Event\BeforeClientProfileUpdateEvent; +use Box\Mod\Staff\Event\BeforeAdminLoginEvent; +use Box\Mod\Support\Event\BeforeGuestTicketCreateEvent; use Symfony\Component\HttpFoundation\Request; use function Tests\Helpers\container; @@ -22,137 +28,205 @@ test('dependency injection', function (): void { expect($getDi)->toEqual($di); }); -test('on before client sign up', function (): void { - $service = new Box\Mod\Antispam\Service(); +test('before client signup checks spam and disposable email without re-verifying captcha', function (): void { + $input = [ + 'ip' => '1.2.3.4', + 'email' => 'test@example.com', + 'bio' => '', + 'g-recaptcha-response' => 'already-verified-token', + ]; $spamCheckerService = Mockery::mock(Box\Mod\Antispam\Service::class); - $spamCheckerService->shouldReceive('isBlockedIp') - ->atLeast()->once(); - $spamCheckerService->shouldReceive('isSpam') - ->atLeast()->once(); - $spamCheckerService->shouldReceive('isTemp') - ->atLeast()->once(); - $spamCheckerService->shouldReceive('checkHoneypot') - ->atLeast()->once(); + $spamCheckerService->shouldReceive('isInStopForumSpamDatabase') + ->once() + ->with(['ip' => $input['ip'], 'email' => $input['email']]) + ->ordered() + ->andReturnFalse(); + $spamCheckerService->shouldReceive('isATempEmail') + ->once() + ->with($input['email'], true) + ->ordered() + ->andReturnFalse(); + $service = Mockery::mock(Box\Mod\Antispam\Service::class)->makePartial(); + $service->shouldReceive('checkCaptcha')->never(); $di = container(); + $di['mod_config'] = $di->protect(fn (): array => [ + 'block_ips' => false, + 'sfs' => true, + 'check_temp_emails' => true, + 'honeypot_enabled' => true, + 'captcha_enabled' => true, + ]); + $di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => $input['ip']]); $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $spamCheckerService); - $boxEventMock = Mockery::mock('\Box_Event'); - $boxEventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); + $service->setDi($di); - $service->onBeforeClientSignUp($boxEventMock); + $service->onBeforeClientSignUp(new BeforeClientSignUpEvent($input)); }); -test('on before client open ticket checks guest submissions', function (): void { +test('before client profile update rejects a blocked IP', function (): void { $service = new Box\Mod\Antispam\Service(); - $spamCheckerService = Mockery::mock(Box\Mod\Antispam\Service::class); - $spamCheckerService->shouldReceive('isBlockedIp') - ->atLeast()->once(); - $spamCheckerService->shouldReceive('checkCaptcha') - ->once(); - $spamCheckerService->shouldReceive('isSpam') - ->atLeast()->once(); - $spamCheckerService->shouldReceive('isTemp') - ->atLeast()->once(); - $di = container(); - $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $spamCheckerService); - $boxEventMock = Mockery::mock('\Box_Event'); - $boxEventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $boxEventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['author_role' => 'guest', 'email' => 'guest@example.com']); + $di['mod_config'] = $di->protect(fn (): array => [ + 'block_ips' => true, + 'blocked_ips' => '1.2.3.4', + ]); + $di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '1.2.3.4']); + $service->setDi($di); - $service->onBeforeClientOpenTicket($boxEventMock); + expect(fn () => $service->onBeforeClientProfileUpdate(new BeforeClientProfileUpdateEvent(42, []))) + ->toThrow(FOSSBilling\InformationException::class, 'Your IP address (1.2.3.4) is blocked'); }); -test('on before client open ticket skips client submissions', function (): void { +test('before admin client update rejects a blocked IP', function (): void { $service = new Box\Mod\Antispam\Service(); - $spamCheckerService = Mockery::mock(Box\Mod\Antispam\Service::class); - $spamCheckerService->shouldReceive('isBlockedIp')->never(); - $spamCheckerService->shouldReceive('checkCaptcha')->never(); - $spamCheckerService->shouldReceive('isSpam')->never(); - $spamCheckerService->shouldReceive('isTemp')->never(); - $di = container(); - $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $spamCheckerService); - $boxEventMock = Mockery::mock('\Box_Event'); - $boxEventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['author_role' => 'client', 'client_id' => 1]); + $di['mod_config'] = $di->protect(fn (): array => [ + 'block_ips' => true, + 'blocked_ips' => '1.2.3.4', + ]); + $di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '1.2.3.4']); + $service->setDi($di); - $service->onBeforeClientOpenTicket($boxEventMock); + expect(fn () => $service->onBeforeAdminClientUpdate(new BeforeAdminClientUpdateEvent(42, []))) + ->toThrow(FOSSBilling\InformationException::class, 'Your IP address (1.2.3.4) is blocked'); }); -test('is spam does not re-verify the captcha during signup', function (): void { - // The signup API already verifies the CAPTCHA before this event fires; - // isSpam() re-checking it would fail every signup since CAPTCHA tokens - // are single-use. +test('before guest ticket creation checks blocked IP, captcha, spam, and disposable email', function (): void { + $input = [ + 'author_role' => 'guest', + 'email' => 'guest@example.com', + 'ip' => '1.2.3.4', + 'g-recaptcha-response' => 'token', + ]; + $service = Mockery::mock(Box\Mod\Antispam\Service::class)->makePartial(); + $service->shouldReceive('checkCaptcha')->once()->with($input); + $service->shouldReceive('isInStopForumSpamDatabase') + ->once() + ->with(['ip' => '1.2.3.4', 'email' => 'guest@example.com']) + ->andReturnTrue(); + $service->shouldReceive('isATempEmail') + ->once() + ->with('guest@example.com', true) + ->andReturnFalse(); + + $di = container(); + $di['mod_config'] = $di->protect(fn (): array => [ + 'block_ips' => false, + 'sfs' => true, + 'check_temp_emails' => true, + ]); + $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $service); + $service->setDi($di); + + $event = new BeforeGuestTicketCreateEvent($input, 'open', 'subject', 'message'); + $service->onBeforeGuestTicketCreate($event); + + expect($event->getStatus())->toBe('open') + ->and($event->getSubject())->toBe('subject') + ->and($event->getMessage())->toBe('message'); +}); + +test('before guest ticket creation skips non-guest submissions', function (): void { + $service = Mockery::mock(Box\Mod\Antispam\Service::class)->makePartial(); + $service->shouldReceive('checkCaptcha')->never(); + $service->shouldReceive('isInStopForumSpamDatabase')->never(); + $service->shouldReceive('isATempEmail')->never(); + + $di = container(); + $di['mod_config'] = $di->protect(fn (): array => [ + 'block_ips' => true, + 'blocked_ips' => '1.2.3.4', + 'sfs' => true, + 'check_temp_emails' => true, + ]); + $di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '1.2.3.4']); + $service->setDi($di); + + $service->onBeforeGuestTicketCreate(new BeforeGuestTicketCreateEvent( + ['author_role' => 'client', 'client_id' => 1], + 'open', + 'subject', + 'message', + )); +}); + +test('before guest ticket creation propagates blocked IP exceptions', function (): void { $service = Mockery::mock(Box\Mod\Antispam\Service::class)->makePartial(); $service->shouldReceive('checkCaptcha')->never(); $di = container(); - $di['mod_config'] = $di->protect(fn (): array => ['sfs' => false]); + $di['mod_config'] = $di->protect(fn (): array => [ + 'block_ips' => true, + 'blocked_ips' => '1.2.3.4', + ]); + $di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '1.2.3.4']); + $service->setDi($di); - $boxEventMock = Mockery::mock('\Box_Event'); - $boxEventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $boxEventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['ip' => '1.2.3.4', 'email' => 'test@example.com']); - - $service->isSpam($boxEventMock); + expect(fn () => $service->onBeforeGuestTicketCreate(new BeforeGuestTicketCreateEvent( + ['author_role' => 'guest', 'email' => 'guest@example.com'], + 'open', + 'subject', + 'message', + )))->toThrow(FOSSBilling\InformationException::class, 'Your IP address (1.2.3.4) is blocked'); }); -test('is blocked ip ip not blocked', function (): void { +test('client login allows an IP absent from the block list', function (): void { $service = new Box\Mod\Antispam\Service(); - $clientIp = '214.1.4.99'; $modConfig = [ 'block_ips' => true, 'blocked_ips' => '1.1.1.1' . PHP_EOL . '2.2.2.2', ]; $di = container(); - $di['request'] = Request::createFromGlobals(); + $di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '214.1.4.99']); $di['mod_config'] = $di->protect(function ($modName) use ($modConfig) { if ($modName == 'Antispam') { return $modConfig; } }); + $service->setDi($di); - $boxEventMock = Mockery::mock('\Box_Event'); - $boxEventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - - $service->isBlockedIp($boxEventMock); + expect(fn () => $service->onBeforeClientLogin(new BeforeClientLoginEvent('214.1.4.99'))) + ->not->toThrow(Throwable::class); }); -test('is blocked ip block ips not enabled', function (): void { +test('admin login allows an IP when blocking is disabled', function (): void { $service = new Box\Mod\Antispam\Service(); $modConfig = [ 'block_ips' => false, ]; $di = container(); + $di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '1.2.3.4']); $di['mod_config'] = $di->protect(function ($modName) use ($modConfig) { if ($modName == 'Antispam') { return $modConfig; } }); + $service->setDi($di); - $boxEventMock = Mockery::mock('\Box_Event'); - $boxEventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - - $service->isBlockedIp($boxEventMock); + expect(fn () => $service->onBeforeAdminLogin(new BeforeAdminLoginEvent('1.2.3.4'))) + ->not->toThrow(Throwable::class); }); +test('login listeners reject a blocked IP', function (string $listener, string $eventClass): void { + $service = new Box\Mod\Antispam\Service(); + $di = container(); + $di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '1.2.3.4']); + $di['mod_config'] = $di->protect(fn (): array => [ + 'block_ips' => true, + 'blocked_ips' => '1.2.3.4', + ]); + $service->setDi($di); + + expect(fn () => $service->{$listener}(new $eventClass('1.2.3.4'))) + ->toThrow(FOSSBilling\InformationException::class, 'Your IP address (1.2.3.4) is blocked'); +})->with([ + ['onBeforeClientLogin', BeforeClientLoginEvent::class], + ['onBeforeAdminLogin', BeforeAdminLoginEvent::class], +]); + dataset('spam responses', fn (): array => [ [ '{"success" : "true", "username" : {"appears" : "true" }}', 'Your username is blacklisted in the Stop Forum Spam database', diff --git a/src/modules/Cart/Event/AfterProductAddedToCartEvent.php b/src/modules/Cart/Event/AfterProductAddedToCartEvent.php new file mode 100644 index 000000000..5ce51a617 --- /dev/null +++ b/src/modules/Cart/Event/AfterProductAddedToCartEvent.php @@ -0,0 +1,22 @@ + $cart->getId(), 'product_id' => $this->getProductId($product)]; - $this->di['events_manager']->fire(['event' => 'onBeforeProductAddedToCart', 'params' => $event_params]); + $cartId = (int) $cart->getId(); + $productId = $this->getProductId($product); + $this->di['event_dispatcher']->dispatch(new BeforeProductAddedToCartEvent($cartId, $productId)); $productService = $this->getProductService()->getProductModuleService($product); @@ -347,7 +354,7 @@ class Service implements InjectionAwareInterface $this->di['logger']->info('Added "{product_title}" to shopping cart', ['product_title' => $this->getProductTitle($product)]); - $this->di['events_manager']->fire(['event' => 'onAfterProductAddedToCart', 'params' => $event_params]); + $this->di['event_dispatcher']->dispatch(new AfterProductAddedToCartEvent($cartId, $productId)); return true; } @@ -887,16 +894,11 @@ class Service implements InjectionAwareInterface $this->assertPromoCartConditionMet($cart, $promo); } - $this->di['events_manager']->fire( - [ - 'event' => 'onBeforeClientCheckout', - 'params' => [ - 'ip' => $this->di['request']->getClientIp(), - 'client_id' => (int) $client->getId(), - 'cart_id' => $cart->getId(), - ], - ] - ); + $this->di['event_dispatcher']->dispatch(new BeforeClientCheckoutEvent( + (int) $cart->getId(), + (int) $client->getId(), + $this->di['request']->getClientIp(), + )); [$order, $invoice, $orders] = $this->createFromCart($client, $gateway_id); @@ -904,16 +906,11 @@ class Service implements InjectionAwareInterface $this->di['logger']->info('Checked out shopping cart'); - $this->di['events_manager']->fire( - [ - 'event' => 'onAfterClientOrderCreate', - 'params' => [ - 'ip' => $this->di['request']->getClientIp(), - 'client_id' => (int) $client->getId(), - 'id' => $order->getId(), - ], - ] - ); + $this->di['event_dispatcher']->dispatch(new AfterClientOrderCreateEvent( + (int) $order->getId(), + (int) $client->getId(), + $this->di['request']->getClientIp(), + )); $result = [ 'gateway_id' => $gateway_id, @@ -971,16 +968,11 @@ class Service implements InjectionAwareInterface $this->assertPromoCartConditionMet($basket, $promo); } - $this->di['events_manager']->fire( - [ - 'event' => 'onBeforeStaffCheckout', - 'params' => [ - 'admin_id' => $adminId, - 'client_id' => (int) $client->getId(), - 'cart_id' => $basket->getId(), - ], - ] - ); + $this->di['event_dispatcher']->dispatch(new BeforeStaffCheckoutEvent( + $adminId, + (int) $client->getId(), + (int) $basket->getId(), + )); [$order, $invoice, $orders] = $this->createOrdersFromCart($basket, $client, [ 'gateway_id' => $options['gateway_id'] ?? null, @@ -992,16 +984,11 @@ class Service implements InjectionAwareInterface $this->di['logger']->info('Checked out staff basket for client #{client_id}', ['client_id' => $client->getId()]); - $this->di['events_manager']->fire( - [ - 'event' => 'onAfterStaffOrderCreate', - 'params' => [ - 'admin_id' => $adminId, - 'client_id' => (int) $client->getId(), - 'id' => $order->getId(), - ], - ] - ); + $this->di['event_dispatcher']->dispatch(new AfterStaffOrderCreateEvent( + $adminId, + (int) $client->getId(), + (int) $order->getId(), + )); $result = [ 'gateway_id' => $options['gateway_id'] ?? null, diff --git a/src/modules/Cart/tests/Unit/ServiceTest.php b/src/modules/Cart/tests/Unit/ServiceTest.php index 095d15270..1c98f3f82 100644 --- a/src/modules/Cart/tests/Unit/ServiceTest.php +++ b/src/modules/Cart/tests/Unit/ServiceTest.php @@ -12,6 +12,8 @@ declare(strict_types=1); use Box\Mod\Cart\Entity\Cart; use Box\Mod\Cart\Entity\CartProduct; +use Box\Mod\Cart\Event\AfterProductAddedToCartEvent; +use Box\Mod\Cart\Event\BeforeProductAddedToCartEvent; use Box\Mod\Cart\Repository\CartProductRepository; use Box\Mod\Cart\Repository\CartRepository; use Box\Mod\Cart\Service; @@ -25,6 +27,7 @@ use Box\Mod\Product\Entity\Product; use Box\Mod\Product\Entity\Promo; use Box\Mod\Product\Entity\PromoRedemption; use Box\Mod\Product\Service as ProductService; +use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher; use Symfony\Component\HttpFoundation\Request; use function Tests\Helpers\container; @@ -57,6 +60,21 @@ function createPromoEntity(int $id): Promo return $promo; } +/** @return array{SymfonyEventDispatcher, ArrayObject} */ +function cartAddItemEventDispatcher(): array +{ + $events = new ArrayObject(); + $dispatcher = new SymfonyEventDispatcher(); + + foreach ([BeforeProductAddedToCartEvent::class, AfterProductAddedToCartEvent::class] as $eventClass) { + $dispatcher->addListener($eventClass, static function (object $event) use ($events): void { + $events->append($event); + }); + } + + return [$dispatcher, $events]; +} + test('gets dependency injection container', function (): void { $service = new Service(); @@ -715,8 +733,19 @@ test('checkoutCart returns array with expected keys', function (): void { $serviceMock->shouldReceive('isPromoAvailableForClientGroup')->atLeast()->once()->andReturn(true); $serviceMock->shouldReceive('getCartProducts')->once()->with($cart)->andReturn([]); - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $events = new ArrayObject(); + $dispatcher = new readonly class($events) { + public function __construct(private ArrayObject $events) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events->append($event); + + return $event; + } + }; $invoice = createEntity(Invoice::class); @@ -731,9 +760,9 @@ test('checkoutCart returns array with expected keys', function (): void { $productService->shouldReceive('findMissingRequiredProductIds')->once()->with($promo, [])->andReturn([]); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $dispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); - $di['request'] = new Request(); + $di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '192.0.2.1']); $di['mod_service'] = $di->protect(fn () => $productService); $serviceMock->setDi($di); @@ -744,6 +773,10 @@ test('checkoutCart returns array with expected keys', function (): void { expect($result)->toHaveKey('invoice_hash'); expect($result)->toHaveKey('order_id'); expect($result)->toHaveKey('orders'); + expect($events->getArrayCopy())->toEqual([ + new Box\Mod\Cart\Event\BeforeClientCheckoutEvent((int) $cart->getId(), (int) $client->getId(), '192.0.2.1'), + new Box\Mod\Order\Event\AfterClientOrderCreateEvent(99, (int) $client->getId(), '192.0.2.1'), + ]); }); test('checkoutCart throws exception when client is not able to use promo', function (): void { @@ -1732,8 +1765,7 @@ test('addItem throws exception when recurring payment period param missing', fun $data = []; - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new SymfonyEventDispatcher(); $serviceHostingServiceMock = Mockery::mock(Box\Mod\Servicehosting\Service::class)->shouldIgnoreMissing(); $serviceMock = Mockery::mock(Service::class)->makePartial(); @@ -1741,7 +1773,7 @@ test('addItem throws exception when recurring payment period param missing', fun $productService = new ProductService(); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) { if ($name === 'Product') { return $productService; @@ -1766,8 +1798,7 @@ test('addItem throws exception when recurring payment period is not enabled', fu $data = ['period' => '1W']; - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new SymfonyEventDispatcher(); $serviceHostingServiceMock = Mockery::mock(Box\Mod\Servicehosting\Service::class)->shouldIgnoreMissing(); @@ -1777,7 +1808,7 @@ test('addItem throws exception when recurring payment period is not enabled', fu $productService = new ProductService(); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) { if ($name === 'Product') { return $productService; @@ -1803,8 +1834,7 @@ test('addItem throws exception when out of stock', function (): void { $data = []; - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new SymfonyEventDispatcher(); $serviceHostingServiceMock = Mockery::mock(Box\Mod\Servicehosting\Service::class)->shouldIgnoreMissing(); @@ -1821,7 +1851,7 @@ test('addItem throws exception when out of stock', function (): void { $productService = new ProductService(); $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) { if ($name === 'Product') { return $productService; @@ -1850,8 +1880,7 @@ test('addItem rejects cumulative stock overflow', function (): void { $existingCartProduct->product_id = 7; $existingCartProduct->config = json_encode(['quantity' => 1]); - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new SymfonyEventDispatcher(); $serviceHostingServiceMock = Mockery::mock(Box\Mod\Servicehosting\Service::class)->shouldIgnoreMissing(); $productServiceMock = Mockery::mock(ProductService::class)->shouldIgnoreMissing(); @@ -1868,7 +1897,7 @@ test('addItem rejects cumulative stock overflow', function (): void { $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productServiceMock) { if ($name === 'Product') { return $productServiceMock; @@ -1900,8 +1929,7 @@ test('addItem rejects duplicate domain register', function (): void { $emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class); $emMock->shouldReceive('getRepository')->with(CartProduct::class)->andReturn($cartProductRepo); - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new SymfonyEventDispatcher(); $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldReceive('isRecurrentPricing')->atLeast()->once()->andReturn(false); @@ -1911,7 +1939,7 @@ test('addItem rejects duplicate domain register', function (): void { $productService = new ProductService(); $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) { if ($name === 'Product') { return $productService; @@ -1943,8 +1971,7 @@ test('addItem rejects duplicate domain transfer', function (): void { $emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class); $emMock->shouldReceive('getRepository')->with(CartProduct::class)->andReturn($cartProductRepo); - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new SymfonyEventDispatcher(); $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldReceive('isRecurrentPricing')->atLeast()->once()->andReturn(false); @@ -1954,7 +1981,7 @@ test('addItem rejects duplicate domain transfer', function (): void { $productService = new ProductService(); $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) { if ($name === 'Product') { return $productService; @@ -1988,8 +2015,7 @@ test('addItem rejects duplicate domain nested', function (): void { $emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class); $emMock->shouldReceive('getRepository')->with(CartProduct::class)->andReturn($cartProductRepo); - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new SymfonyEventDispatcher(); $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldReceive('isRecurrentPricing')->atLeast()->once()->andReturn(false); @@ -1999,7 +2025,7 @@ test('addItem rejects duplicate domain nested', function (): void { $productService = new ProductService(); $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) { if ($name === 'Product') { return $productService; @@ -2024,8 +2050,7 @@ test('addItem for hosting type returns true', function (): void { $data = []; - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new SymfonyEventDispatcher(); $productDomainModel = createProductEntity(type: 'domain'); $domainProduct = ['config' => [], 'product' => $productDomainModel]; @@ -2048,7 +2073,7 @@ test('addItem for hosting type returns true', function (): void { $productService = new ProductService(); $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) { if ($name === 'Product') { return $productService; @@ -2073,8 +2098,7 @@ test('addItem for license type returns true', function (): void { $data = []; - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new SymfonyEventDispatcher(); $serviceLicenseServiceMock = Mockery::mock(Box\Mod\Servicelicense\Service::class)->shouldIgnoreMissing(); $serviceLicenseServiceMock->shouldReceive('attachOrderConfig')->atLeast()->once()->andReturn([]); @@ -2094,7 +2118,7 @@ test('addItem for license type returns true', function (): void { $productService = new ProductService(); $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(function ($name) use ($serviceLicenseServiceMock, $productService) { if ($name === 'Product') { return $productService; @@ -2119,8 +2143,7 @@ test('addItem for custom type returns true', function (): void { $data = []; - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new SymfonyEventDispatcher(); $serviceCustomServiceMock = Mockery::mock(Box\Mod\Servicecustom\Service::class); $serviceCustomServiceMock->shouldReceive('validateCustomForm')->atLeast()->once(); @@ -2140,7 +2163,7 @@ test('addItem for custom type returns true', function (): void { $productService = new ProductService(); $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(function ($name) use ($serviceCustomServiceMock, $productService) { if ($name === 'Product') { return $productService; @@ -2415,8 +2438,7 @@ test('addItem strips client-injected hosting_plan_id', function (): void { 'multiple' => 1, ]; - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new SymfonyEventDispatcher(); $productDomainModel = createProductEntity(type: 'domain'); $domainProduct = ['config' => [], 'product' => $productDomainModel]; @@ -2452,7 +2474,7 @@ test('addItem strips client-injected hosting_plan_id', function (): void { $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) { if ($name === 'Product') { return $productService; @@ -2510,8 +2532,7 @@ test('addItem stamps one shared cart family across parent and addons', function $parentModel = createProductEntity(id: 5, type: 'custom'); $addonModel = createProductEntity(id: 9, type: 'custom'); - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + [$eventDispatcher, $events] = cartAddItemEventDispatcher(); $cartProductRepo = Mockery::mock(CartProductRepository::class); $cartProductRepo->shouldReceive('findByCartId')->atLeast()->once()->andReturn([]); @@ -2542,12 +2563,24 @@ test('addItem stamps one shared cart family across parent and addons', function $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(fn ($name) => $name === 'Product' ? $productServiceMock : new stdClass()); $di['logger'] = new FOSSBilling\Logger(); $serviceMock->setDi($di); - expect($serviceMock->addItem($cartModel, $parentModel, ['addons' => ['9' => ['selected' => true]]]))->toBeTrue(); + $addData = [ + 'addons' => ['9' => ['selected' => true]], + 'domain' => [ + 'action' => 'transfer', + 'transfer_sld' => 'example', + 'transfer_tld' => '.com', + 'transfer_code' => 'secret-epp-code', + ], + 'hosting_password' => 'secret-hosting-password', + 'custom_field' => 'public-value', + ]; + + expect($serviceMock->addItem($cartModel, $parentModel, $addData))->toBeTrue(); expect($storedConfigs)->toHaveCount(2); $firstFamily = $storedConfigs[0][Service::CART_FAMILY_KEY] ?? null; @@ -2562,6 +2595,24 @@ test('addItem stamps one shared cart family across parent and addons', function expect($secondFamily)->toBeString(); expect($secondFamily)->not->toBe(''); expect($secondFamily)->not->toBe($firstFamily); + + expect(array_map(static fn (object $event): string => $event::class, $events->getArrayCopy())) + ->toBe([ + BeforeProductAddedToCartEvent::class, + AfterProductAddedToCartEvent::class, + BeforeProductAddedToCartEvent::class, + AfterProductAddedToCartEvent::class, + ]); + expect($events[0]->cartId)->toBe(1); + expect($events[0]->productId)->toBe(5); + expect(array_keys(get_object_vars($events[0])))->toBe(['cartId', 'productId']); + expect($events[1]->cartId)->toBe(1); + expect($events[1]->productId)->toBe(5); + expect(array_keys(get_object_vars($events[1])))->toBe(['cartId', 'productId']); + expect($events[2]->cartId)->toBe(1); + expect($events[2]->productId)->toBe(5); + expect($events[3]->cartId)->toBe(1); + expect($events[3]->productId)->toBe(5); }); test('getEffectiveCartPromos returns the manual promo when a code is set', function (): void { diff --git a/src/modules/Cart/tests/Unit/StaffBasketTest.php b/src/modules/Cart/tests/Unit/StaffBasketTest.php index aa44fbca6..4b3461ba8 100644 --- a/src/modules/Cart/tests/Unit/StaffBasketTest.php +++ b/src/modules/Cart/tests/Unit/StaffBasketTest.php @@ -12,6 +12,8 @@ declare(strict_types=1); use Box\Mod\Cart\Entity\Cart; use Box\Mod\Cart\Entity\CartProduct; +use Box\Mod\Cart\Event\AfterStaffOrderCreateEvent; +use Box\Mod\Cart\Event\BeforeStaffCheckoutEvent; use Box\Mod\Cart\Repository\CartProductRepository; use Box\Mod\Cart\Repository\CartRepository; use Box\Mod\Cart\Service; @@ -23,6 +25,7 @@ use Box\Mod\Invoice\Entity\Invoice; use Box\Mod\Order\Entity\Order; use Box\Mod\Product\Entity\Product; use Box\Mod\Product\Service as ProductService; +use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher; use function Tests\Helpers\container; use function Tests\Helpers\createEntity; @@ -112,8 +115,7 @@ test('addItem strips a forged price override from client input', function (): vo $productModel = createProductEntity(id: 5, type: 'custom'); - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new SymfonyEventDispatcher(); $cartProductRepo = Mockery::mock(CartProductRepository::class); $cartProductRepo->shouldReceive('findByCartId')->atLeast()->once()->andReturn([]); @@ -140,7 +142,7 @@ test('addItem strips a forged price override from client input', function (): vo $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(fn ($name) => $name === 'Product' ? $productServiceMock : new stdClass()); $di['logger'] = new FOSSBilling\Logger(); $serviceMock->setDi($di); @@ -158,8 +160,7 @@ test('addItem stamps a staff price override on the main row only', function (): $parentModel = createProductEntity(id: 5, type: 'custom'); $addonModel = createProductEntity(id: 9, type: 'custom'); - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new SymfonyEventDispatcher(); $cartProductRepo = Mockery::mock(CartProductRepository::class); $cartProductRepo->shouldReceive('findByCartId')->atLeast()->once()->andReturn([]); @@ -188,7 +189,7 @@ test('addItem stamps a staff price override on the main row only', function (): $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(fn ($name) => $name === 'Product' ? $productServiceMock : new stdClass()); $di['logger'] = new FOSSBilling\Logger(); $serviceMock->setDi($di); @@ -258,12 +259,12 @@ test('checkoutStaffBasket checks out and destroys the basket', function (): void $productServiceMock = Mockery::mock(ProductService::class); $events = []; - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); - $eventMock->shouldReceive('fire')->andReturnUsing(function (array $event) use (&$events): bool { - $events[] = $event; - - return true; - }); + $eventDispatcher = new SymfonyEventDispatcher(); + foreach ([BeforeStaffCheckoutEvent::class, AfterStaffOrderCreateEvent::class] as $eventClass) { + $eventDispatcher->addListener($eventClass, static function (object $event) use (&$events): void { + $events[] = $event; + }); + } $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldReceive('createOrdersFromCart') @@ -273,7 +274,7 @@ test('checkoutStaffBasket checks out and destroys the basket', function (): void $serviceMock->shouldReceive('rm')->once()->with($basket)->andReturn(true); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new FOSSBilling\Logger(); $di['mod_service'] = $di->protect(fn ($name) => $name === 'Product' ? $productServiceMock : new stdClass()); $serviceMock->setDi($di); @@ -285,7 +286,9 @@ test('checkoutStaffBasket checks out and destroys the basket', function (): void expect($result['invoice_hash'])->toBe('basket-hash'); expect($result['order_id'])->toBe(21); expect($result['orders'])->toBe([21, 22]); - expect(array_column($events, 'event'))->toBe(['onBeforeStaffCheckout', 'onAfterStaffOrderCreate']); + expect(array_map(static fn (object $event): string => $event::class, $events))->toBe([BeforeStaffCheckoutEvent::class, AfterStaffOrderCreateEvent::class]); + expect($events[0]->cartId)->toBe(3); + expect($events[1]->orderId)->toBe(21); }); test('checkoutStaffBasket marks the invoice paid when requested', function (): void { @@ -313,14 +316,14 @@ test('checkoutStaffBasket marks the invoice paid when requested', function (): v ->with($invoice, ['gateway_id' => 5, 'transactionId' => 'txn-1']) ->andReturn(true); - $eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing(); + $eventDispatcher = new SymfonyEventDispatcher(); $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldReceive('createOrdersFromCart')->once()->andReturn([$masterOrder, $invoice, [21]]); $serviceMock->shouldReceive('rm')->once()->andReturn(true); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new FOSSBilling\Logger(); $di['mod_service'] = $di->protect(fn ($name) => match ($name) { 'Product' => $productServiceMock, diff --git a/src/modules/Client/Api/Admin.php b/src/modules/Client/Api/Admin.php index 6a955626d..8d1b28727 100644 --- a/src/modules/Client/Api/Admin.php +++ b/src/modules/Client/Api/Admin.php @@ -18,6 +18,12 @@ namespace Box\Mod\Client\Api; use Box\Mod\Client\Entity\Client; use Box\Mod\Client\Entity\ClientBalance; use Box\Mod\Client\Entity\ClientGroup; +use Box\Mod\Client\Event\AfterAdminClientDeleteEvent; +use Box\Mod\Client\Event\AfterAdminClientPasswordChangeEvent; +use Box\Mod\Client\Event\AfterAdminClientUpdateEvent; +use Box\Mod\Client\Event\BeforeAdminClientDeleteEvent; +use Box\Mod\Client\Event\BeforeAdminClientPasswordChangeEvent; +use Box\Mod\Client\Event\BeforeAdminClientUpdateEvent; use FOSSBilling\InformationException; use FOSSBilling\PaginationOptions; use FOSSBilling\Tools; @@ -217,11 +223,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi $validator->isPasswordStrong($data['password']); } - $this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminClientCreate', 'params' => $data]); - $id = $service->adminCreateClient($data); - $this->getDi()['events_manager']->fire(['event' => 'onAfterAdminClientCreate', 'params' => $data]); - - return $id; + return $service->adminCreateClient($data); } /** @@ -234,12 +236,12 @@ class Admin extends \FOSSBilling\Api\AbstractApi $model = $this->getDi()['em']->getRepository(Client::class)->find($data['id']) ?? throw new InformationException('Client not found'); - $clientId = $model->getId(); + $clientId = (int) $model->getId(); - $this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminClientDelete', 'params' => ['id' => $clientId]]); + $this->getDi()['event_dispatcher']->dispatch(new BeforeAdminClientDeleteEvent($clientId)); $this->getService()->remove($model); - $this->getDi()['events_manager']->fire(['event' => 'onAfterAdminClientDelete', 'params' => ['id' => $clientId]]); + $this->getDi()['event_dispatcher']->dispatch(new AfterAdminClientDeleteEvent($clientId)); $this->getDi()['logger']->info('Removed client #{client_id}', ['client_id' => $clientId]); @@ -329,7 +331,9 @@ class Admin extends \FOSSBilling\Api\AbstractApi $client->setCurrency($currency); } - $this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminClientUpdate', 'params' => $data]); + $eventInput = $data; + unset($eventInput['password'], $eventInput['password_confirm']); + $this->getDi()['event_dispatcher']->dispatch(new BeforeAdminClientUpdateEvent((int) $client->getId(), $eventInput)); // Special handling for the phone country codes $phoneCountryCode = $data['phone_cc'] ?? $client->getPhoneCc(); @@ -460,7 +464,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi $profileService->invalidateSessions('client', (int) $client->getId()); } - $this->getDi()['events_manager']->fire(['event' => 'onAfterAdminClientUpdate', 'params' => ['id' => $client->getId()]]); + $this->getDi()['event_dispatcher']->dispatch(new AfterAdminClientUpdateEvent((int) $client->getId())); $this->getDi()['logger']->info('Updated client #{client_id} profile', ['client_id' => $client->getId()]); @@ -481,7 +485,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi $client = $this->getDi()['em']->getRepository(Client::class)->find($data['id']) ?? throw new InformationException('Client not found'); - $this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminClientPasswordChange', 'params' => ['id' => $client->getId()]]); + $this->getDi()['event_dispatcher']->dispatch(new BeforeAdminClientPasswordChangeEvent((int) $client->getId())); $client->setPass($this->getDi()['password']->hashIt($data['password'])); $this->getDi()['em']->persist($client); @@ -490,7 +494,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi $profileService = $this->getDi()['mod_service']('profile'); $profileService->invalidateSessions('client', (int) $data['id']); - $this->getDi()['events_manager']->fire(['event' => 'onAfterAdminClientPasswordChange', 'params' => ['id' => $client->getId()]]); + $this->getDi()['event_dispatcher']->dispatch(new AfterAdminClientPasswordChangeEvent((int) $client->getId())); $this->getDi()['logger']->info('Changed client #{client_id} password', ['client_id' => $client->getId()]); diff --git a/src/modules/Client/Api/Guest.php b/src/modules/Client/Api/Guest.php index 38f25341a..a77e2e06e 100644 --- a/src/modules/Client/Api/Guest.php +++ b/src/modules/Client/Api/Guest.php @@ -17,6 +17,13 @@ namespace Box\Mod\Client\Api; use Box\Mod\Client\Entity\Client; use Box\Mod\Client\Entity\ClientPasswordReset; +use Box\Mod\Client\Event\AfterClientLoginEvent; +use Box\Mod\Client\Event\AfterClientPasswordResetEvent; +use Box\Mod\Client\Event\BeforeClientLoginEvent; +use Box\Mod\Client\Event\BeforeClientPasswordResetConfirmationEvent; +use Box\Mod\Client\Event\BeforeClientPasswordResetEvent; +use Box\Mod\Client\Event\BeforeClientPasswordResetRequestEvent; +use Box\Mod\Client\Event\ClientLoginFailedEvent; use Box\Mod\Client\Service; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; @@ -233,20 +240,18 @@ class Guest extends \FOSSBilling\Api\AbstractApi try { $this->getDi()['tools']->validateAndSanitizeEmail($data['email'], true, false); - $event_params = $data; - $event_params['ip'] = $this->ip; - $this->getDi()['events_manager']->fire(['event' => 'onBeforeClientLogin', 'params' => $event_params]); + $this->getDi()['event_dispatcher']->dispatch(new BeforeClientLoginEvent($this->ip)); $service = $this->getService(); $client = $service->authorizeClient($data['email'], $data['password']); if (!$client instanceof Client) { - $this->getDi()['events_manager']->fire(['event' => 'onEventClientLoginFailed', 'params' => $event_params]); + $this->getDi()['event_dispatcher']->dispatch(new ClientLoginFailedEvent($this->ip)); throw new \FOSSBilling\InformationException('Please check your login details.', [], 401); } - $this->getDi()['events_manager']->fire(['event' => 'onAfterClientLogin', 'params' => ['id' => $client->getId(), 'ip' => $this->ip]]); + $this->getDi()['event_dispatcher']->dispatch(new AfterClientLoginEvent((int) $client->getId(), $this->ip)); $oldSession = $this->getDi()['session']->getId(); $this->getDi()['session']->regenerateId(); @@ -279,7 +284,7 @@ class Guest extends \FOSSBilling\Api\AbstractApi $startedAt = microtime(true); try { - $this->getDi()['events_manager']->fire(['event' => 'onBeforePasswordResetClient']); + $this->getDi()['event_dispatcher']->dispatch(new BeforeClientPasswordResetEvent($this->getIp())); $service = $this->getDi()['mod_service']('client'); // Sanitize email @@ -301,7 +306,7 @@ class Guest extends \FOSSBilling\Api\AbstractApi $this->checkCaptchaIfEnabled($data); - $this->getDi()['events_manager']->fire(['event' => 'onBeforeGuestPasswordResetRequest', 'params' => $data]); + $this->getDi()['event_dispatcher']->dispatch(new BeforeClientPasswordResetRequestEvent($this->getIp())); $em = $this->getDi()['em']; $client = $em->getRepository(Client::class)->findOneByEmailAndActive($data['email']); @@ -336,7 +341,7 @@ class Guest extends \FOSSBilling\Api\AbstractApi try { $this->getDi()['rate_limiter']->consumeOrThrow('client_password_reset_confirm_post_ip', (string) $this->getIp()); - $this->getDi()['events_manager']->fire(['event' => 'onBeforeClientProfilePasswordReset', 'params' => $data['hash']]); + $this->getDi()['event_dispatcher']->dispatch(new BeforeClientPasswordResetConfirmationEvent($this->getIp())); $this->getDi()['validator']->passwordsMatch($data); $this->getDi()['validator']->isPasswordStrong($data['password']); @@ -382,7 +387,7 @@ class Guest extends \FOSSBilling\Api\AbstractApi $email['code'] = 'mod_client_password_reset_information'; $emailService = $this->getDi()['mod_service']('email'); $emailService->sendTemplate($email); - $this->getDi()['events_manager']->fire(['event' => 'onAfterClientProfilePasswordReset', 'params' => ['id' => $client->getId()]]); + $this->getDi()['event_dispatcher']->dispatch(new AfterClientPasswordResetEvent((int) $client->getId())); return true; } finally { diff --git a/src/modules/Client/Controller/Client.php b/src/modules/Client/Controller/Client.php index a2076e98c..78b987d49 100644 --- a/src/modules/Client/Controller/Client.php +++ b/src/modules/Client/Controller/Client.php @@ -88,7 +88,6 @@ class Client implements \FOSSBilling\InjectionAwareInterface } $service = $this->di['mod_service']('client'); - $this->di['events_manager']->fire(['event' => 'onBeforePasswordResetClient']); $data = [ 'hash' => $hash, ]; diff --git a/src/modules/Client/Event/AfterAdminClientCreateEvent.php b/src/modules/Client/Event/AfterAdminClientCreateEvent.php new file mode 100644 index 000000000..0e82ac8b8 --- /dev/null +++ b/src/modules/Client/Event/AfterAdminClientCreateEvent.php @@ -0,0 +1,21 @@ + $input */ + public function __construct( + public readonly array $input, + ) { + } +} diff --git a/src/modules/Client/Event/BeforeAdminClientDeleteEvent.php b/src/modules/Client/Event/BeforeAdminClientDeleteEvent.php new file mode 100644 index 000000000..36cc74c67 --- /dev/null +++ b/src/modules/Client/Event/BeforeAdminClientDeleteEvent.php @@ -0,0 +1,20 @@ + $input Validated input, excluding credentials. */ + public function __construct( + public readonly int $clientId, + public readonly array $input, + ) { + } +} diff --git a/src/modules/Client/Event/BeforeClientLoginEvent.php b/src/modules/Client/Event/BeforeClientLoginEvent.php new file mode 100644 index 000000000..b2d6bfb6f --- /dev/null +++ b/src/modules/Client/Event/BeforeClientLoginEvent.php @@ -0,0 +1,20 @@ + $input */ + public function __construct( + public readonly array $input, + ) { + } +} diff --git a/src/modules/Client/Event/ClientLoginFailedEvent.php b/src/modules/Client/Event/ClientLoginFailedEvent.php new file mode 100644 index 000000000..d5c01f0d4 --- /dev/null +++ b/src/modules/Client/Event/ClientLoginFailedEvent.php @@ -0,0 +1,20 @@ +di['tools']->url('/client/confirm-email/' . $hash); } - public static function onAfterClientSignUp(\Box_Event $event): bool + #[AsEventListener] + public function sendSignupEmail(AfterClientSignUpEvent $event): void { - $di = $event->getDi(); - $params = $event->getParameters(); + $di = $this->di ?? throw new \LogicException('Client service must be initialized before handling events.'); $config = $di['mod_config']('client'); $emailService = $di['mod_service']('email'); try { $email = []; - $email['to_client'] = $params['id']; + $email['to_client'] = $event->clientId; $email['code'] = 'mod_client_signup'; $email['require_email_confirmation'] = false; if (isset($config['require_email_confirmation']) && $config['require_email_confirmation']) { $clientService = $di['mod_service']('client'); $email['require_email_confirmation'] = true; - $email['email_confirmation_link'] = $clientService->generateEmailConfirmationLink($params['id']); + $email['email_confirmation_link'] = $clientService->generateEmailConfirmationLink($event->clientId); } $emailService->sendTemplate($email); } catch (\Exception $exc) { $di['logger']->withChannel('email')->error('Failed to send client signup email', ['exception' => $exc]); } - - return true; } public function getSearchQuery($data, $selectStmt = null): array @@ -721,12 +727,12 @@ class Service implements InjectionAwareInterface { $eventParams = $data; unset($eventParams['password'], $eventParams['password_confirm']); - $this->di['events_manager']->fire(['event' => 'onBeforeAdminCreateClient', 'params' => $eventParams]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminClientCreateEvent($eventParams)); $client = $this->createClient($data); if (Tools::normalizeBoolean($data['send_welcome_email'] ?? true, true)) { $this->sendAdminCreatedWelcomeEmailForClient($client); } - $this->di['events_manager']->fire(['event' => 'onAfterAdminCreateClient', 'params' => ['id' => $client->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminClientCreateEvent((int) $client->getId())); $this->di['logger']->info('Created new client #{client_id}', ['client_id' => $client->getId()]); return (int) $client->getId(); @@ -737,7 +743,7 @@ class Service implements InjectionAwareInterface $event_params = $data; $event_params['ip'] = $this->di['request']->getClientIp(); unset($event_params['password'], $event_params['password_confirm']); - $this->di['events_manager']->fire(['event' => 'onBeforeClientSignUp', 'params' => $event_params]); + $this->di['event_dispatcher']->dispatch(new BeforeClientSignUpEvent($event_params)); $allowedFields = [ 'email', 'first_name', 'last_name', 'password', @@ -763,14 +769,7 @@ class Service implements InjectionAwareInterface $client = $this->createClient($safeData); - $event_params = [ - 'id' => $client->getId(), - 'email' => $client->getEmail(), - 'first_name' => $client->getFirstName(), - 'last_name' => $client->getLastName(), - 'ip' => $safeData['ip'], - ]; - $this->di['events_manager']->fire(['event' => 'onAfterClientSignUp', 'params' => $event_params]); + $this->di['event_dispatcher']->dispatch(new AfterClientSignUpEvent((int) $client->getId())); $this->di['logger']->info('Client #{client_id} signed up', ['client_id' => $client->getId()]); return $client; @@ -1019,7 +1018,9 @@ class Service implements InjectionAwareInterface $required = [ 'hash' => 'Hash required', ]; - $this->di['events_manager']->fire(['event' => 'onBeforePasswordResetClient']); + $request = $this->di['request'] ?? null; + $ip = $request instanceof Request ? $request->getClientIp() : null; + $this->di['event_dispatcher']->dispatch(new BeforeClientPasswordResetEvent($ip)); $this->di['validator']->checkRequiredParamsForArray($required, $data); $reset = $this->clientPasswordResetRepository->findOneByHash($data['hash']); @@ -1044,13 +1045,14 @@ class Service implements InjectionAwareInterface * * @return void */ - public static function onBeforeAdminCronRun(\Box_Event $event): void + #[AsEventListener] + public function removeExpiredPasswordResetRequests(BeforeAdminCronRunEvent $event): void { - $di = $event->getDi(); + $di = $this->di ?? throw new \LogicException('The Client service dependency injection container has not been set.'); try { $cutoff = new \DateTime('-900 seconds'); - $di['em']->getRepository(ClientPasswordReset::class) + $this->clientPasswordResetRepository ->createQueryBuilder('r') ->delete() ->where('r.createdAt < :cutoff') diff --git a/src/modules/Client/tests/Unit/Api/AdminTest.php b/src/modules/Client/tests/Unit/Api/AdminTest.php index 2fe37d8b7..a12e2b070 100644 --- a/src/modules/Client/tests/Unit/Api/AdminTest.php +++ b/src/modules/Client/tests/Unit/Api/AdminTest.php @@ -142,14 +142,10 @@ test('create returns int', function (): void { $serviceMock->shouldReceive('emailAlreadyRegistered')->atLeast()->once()->andReturn(false); $serviceMock->shouldReceive('adminCreateClient')->atLeast()->once()->andReturn(1); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->atLeast()->once(); - $toolsMock = Mockery::mock(FOSSBilling\Tools::class); $toolsMock->shouldReceive('validateAndSanitizeEmail')->atLeast()->once(); $di = container(); - $di['events_manager'] = $eventMock; $di['tools'] = $toolsMock; $adminClient->setDi($di); @@ -186,14 +182,27 @@ test('delete returns true', function (): void { $adminClient = apiEndpoint(new Box\Mod\Client\Api\Admin()); $data = ['id' => 1]; - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $calls = new ArrayObject(); + $dispatcher = new readonly class($calls) { + public function __construct(private ArrayObject $calls) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->calls->append($event); + + return $event; + } + }; $serviceMock = Mockery::mock(Box\Mod\Client\Service::class)->makePartial(); - $serviceMock->shouldReceive('remove')->atLeast()->once(); + $serviceMock->shouldReceive('remove')->once()->andReturnUsing(static function () use ($calls): void { + $calls->append('remove'); + }); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $dispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $validatorStub = $this->createStub(FOSSBilling\Validate::class); $di['validator'] = $validatorStub; @@ -201,7 +210,13 @@ test('delete returns true', function (): void { $adminClient->setDi($di); $adminClient->setService($serviceMock); $result = $adminClient->delete($data); + expect($result)->toBeTrue(); + expect($calls->getArrayCopy())->toEqual([ + new Box\Mod\Client\Event\BeforeAdminClientDeleteEvent(1), + 'remove', + new Box\Mod\Client\Event\AfterAdminClientDeleteEvent(1), + ]); }); test('update returns true', function (): void { @@ -249,21 +264,34 @@ test('update returns true', function (): void { $serviceMock->shouldReceive('emailAlreadyRegistered')->atLeast()->once()->andReturn(false); $serviceMock->shouldReceive('canChangeCurrency')->atLeast()->once()->andReturn(true); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $dispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $toolsMock = Mockery::mock(FOSSBilling\Tools::class); $toolsMock->shouldReceive('validateAndSanitizeEmail')->atLeast()->once(); $di = container(); $di['mod_service'] = $di->protect(moduleService(['client' => $serviceMock])); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $dispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $di['tools'] = $toolsMock; $adminClient->setDi($di); $result = $adminClient->update($data); expect($result)->toBeTrue(); + expect($dispatcher->events)->toHaveCount(2); + expect($dispatcher->events[0])->toBeInstanceOf(Box\Mod\Client\Event\BeforeAdminClientUpdateEvent::class); + expect($dispatcher->events[0]->clientId)->toBe(1); + expect($dispatcher->events[0]->input)->not->toHaveKey('password'); + expect($dispatcher->events[1])->toEqual(new Box\Mod\Client\Event\AfterAdminClientUpdateEvent(1)); }); test('update validates and assigns client_group_id through the group repository', function (): void { @@ -376,12 +404,8 @@ test('update throws exception when email is already registered', function (): vo $serviceMock = Mockery::mock(Box\Mod\Client\Service::class); $serviceMock->shouldReceive('emailAlreadyRegistered')->atLeast()->once()->andReturn(true); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire'); - $di = container(); $di['mod_service'] = $di->protect(moduleService(['client' => $serviceMock])); - $di['events_manager'] = $eventMock; $di['logger'] = new Tests\Helpers\TestLogger(); $di['validator'] = new FOSSBilling\Validate(); @@ -418,8 +442,16 @@ test('changePassword returns true', function (): void { 'password_confirm' => 'strongPass', ]; - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $dispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $passwordMock = Mockery::mock(FOSSBilling\PasswordManager::class); $passwordMock->shouldReceive('hashIt')->atLeast()->once()->with($data['password']); @@ -428,7 +460,7 @@ test('changePassword returns true', function (): void { $profileService->shouldReceive('invalidateSessions')->atLeast()->once(); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $dispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $di['password'] = $passwordMock; $validatorStub = $this->createStub(FOSSBilling\Validate::class); @@ -439,6 +471,10 @@ test('changePassword returns true', function (): void { $result = $adminClient->change_password($data); expect($result)->toBeTrue(); + expect($dispatcher->events)->toEqual([ + new Box\Mod\Client\Event\BeforeAdminClientPasswordChangeEvent(1), + new Box\Mod\Client\Event\AfterAdminClientPasswordChangeEvent(1), + ]); }); test('changePassword throws exception when passwords do not match', function (): void { diff --git a/src/modules/Client/tests/Unit/Api/GuestTest.php b/src/modules/Client/tests/Unit/Api/GuestTest.php index f2139d64d..50dfd92e2 100644 --- a/src/modules/Client/tests/Unit/Api/GuestTest.php +++ b/src/modules/Client/tests/Unit/Api/GuestTest.php @@ -14,6 +14,21 @@ use function Tests\Helpers\container; use function Tests\Helpers\createEntity; use function Tests\Helpers\moduleService; +function clientGuestTestEventDispatcher(): object +{ + return new class { + /** @var list */ + public array $dispatched = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->dispatched[] = $event; + + return $event; + } + }; +} + function guestClientDuplicateKeyViolation(): Doctrine\DBAL\Exception\UniqueConstraintViolationException { $driverException = new class extends Exception implements Doctrine\DBAL\Driver\Exception { @@ -123,6 +138,7 @@ test('create returns true without creating a duplicate account or disclosing tha $toolsMock = Mockery::mock(FOSSBilling\Tools::class); $toolsMock->shouldReceive('validateAndSanitizeEmail')->atLeast()->once()->andReturn($data['email']); $di['tools'] = $toolsMock; + $di['event_dispatcher'] = clientGuestTestEventDispatcher(); $guestClient->setDi($di); $guestClient->setService($serviceMock); @@ -176,6 +192,7 @@ test('create returns true without creating an account when the per-email signup $di['mod_config'] = $di->protect(fn ($name): array => $configArr); $di['validator'] = $validatorMock; $di['rate_limiter'] = $rateLimiterMock; + $di['event_dispatcher'] = clientGuestTestEventDispatcher(); $toolsMock = Mockery::mock(FOSSBilling\Tools::class); $toolsMock->shouldReceive('validateAndSanitizeEmail')->atLeast()->once()->andReturn($data['email']); @@ -282,6 +299,7 @@ test('create returns generic success when a concurrent signup wins the email rac $di['validator'] = $validatorMock; $di['rate_limiter'] = $rateLimiterMock; $di['tools'] = $toolsMock; + $di['event_dispatcher'] = clientGuestTestEventDispatcher(); $guestClient->setDi($di); $guestClient->setService($serviceMock); @@ -395,8 +413,7 @@ test('login returns array', function (): void { ->atLeast()->once() ->andReturn([]); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = clientGuestTestEventDispatcher(); $sessionMock = Mockery::mock(FOSSBilling\Session::class); $sessionMock->shouldReceive('set')->atLeast()->once(); @@ -412,7 +429,7 @@ test('login returns array', function (): void { $toolsStub->shouldReceive('validateAndSanitizeEmail')->atLeast()->once()->with($data['email'], true, false)->andReturn($data['email']); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['session'] = $sessionMock; $di['logger'] = new Tests\Helpers\TestLogger(); $di['tools'] = $toolsStub; @@ -420,18 +437,46 @@ test('login returns array', function (): void { $guestClient->setDi($di); $guestClient->setService($serviceMock); + $guestClient->setIp('192.0.2.1'); $results = $guestClient->login($data); expect($results)->toBeArray(); + expect($eventDispatcher->dispatched)->toHaveCount(2); + expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Client\Event\BeforeClientLoginEvent('192.0.2.1')); + expect($eventDispatcher->dispatched[1])->toEqual(new Box\Mod\Client\Event\AfterClientLoginEvent((int) $model->getId(), '192.0.2.1')); + expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.1']); +}); + +test('failed client login dispatches only IP metadata', function (): void { + $guestClient = apiEndpoint(new Box\Mod\Client\Api\Guest()); + $service = Mockery::mock(Box\Mod\Client\Service::class); + $service->shouldReceive('authorizeClient')->once()->with('test@example.com', 'private-password')->andReturn(null); + $tools = Mockery::mock(FOSSBilling\Tools::class); + $tools->shouldReceive('validateAndSanitizeEmail')->once()->with('test@example.com', true, false)->andReturn('test@example.com'); + $eventDispatcher = clientGuestTestEventDispatcher(); + + $di = container(); + $di['event_dispatcher'] = $eventDispatcher; + $di['tools'] = $tools; + $guestClient->setDi($di); + $guestClient->setService($service); + $guestClient->setIp('192.0.2.2'); + + expect(fn () => $guestClient->login(['email' => 'test@example.com', 'password' => 'private-password'])) + ->toThrow(FOSSBilling\InformationException::class, 'Please check your login details.'); + + expect($eventDispatcher->dispatched)->toHaveCount(2); + expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Client\Event\BeforeClientLoginEvent('192.0.2.2')); + expect($eventDispatcher->dispatched[1])->toEqual(new Box\Mod\Client\Event\ClientLoginFailedEvent('192.0.2.2')); + expect(get_object_vars($eventDispatcher->dispatched[1]))->toBe(['ip' => '192.0.2.2']); }); test('resetPassword returns true with new flow', function (): void { $guestClient = apiEndpoint(new Box\Mod\Client\Api\Guest()); $data['email'] = 'John@exmaple.com'; - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = clientGuestTestEventDispatcher(); $modelClient = createEntity(Box\Mod\Client\Entity\Client::class, ['id' => 1, 'status' => Box\Mod\Client\Entity\Client::ACTIVE]); @@ -452,23 +497,27 @@ test('resetPassword returns true with new flow', function (): void { $di = container(); $di['em'] = $em; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(moduleService(['client' => $serviceMock])); $di['logger'] = new Tests\Helpers\TestLogger(); $di['tools'] = $toolsMock; $guestClient->setDi($di); + $guestClient->setIp('192.0.2.3'); $result = $guestClient->reset_password($data); expect($result)->toBeTrue(); + expect($eventDispatcher->dispatched)->toHaveCount(2); + expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Client\Event\BeforeClientPasswordResetEvent('192.0.2.3')); + expect($eventDispatcher->dispatched[1])->toEqual(new Box\Mod\Client\Event\BeforeClientPasswordResetRequestEvent('192.0.2.3')); + expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.3']); }); test('resetPassword returns true when email not found', function (): void { $guestClient = apiEndpoint(new Box\Mod\Client\Api\Guest()); $data['email'] = 'joghn@example.eu'; - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = clientGuestTestEventDispatcher(); $clientRepository = Mockery::mock(Box\Mod\Client\Repository\ClientRepository::class); $clientRepository->shouldReceive('findOneByEmailAndActive')->atLeast()->once()->andReturn(null); @@ -481,7 +530,7 @@ test('resetPassword returns true when email not found', function (): void { $di = container(); $di['em'] = $em; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $toolsMock = Mockery::mock(FOSSBilling\Tools::class); @@ -489,9 +538,13 @@ test('resetPassword returns true when email not found', function (): void { $di['tools'] = $toolsMock; $guestClient->setDi($di); + $guestClient->setIp('192.0.2.4'); $result = $guestClient->reset_password($data); expect($result)->toBeTrue(); + expect($eventDispatcher->dispatched)->toHaveCount(2); + expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.4']); + expect(get_object_vars($eventDispatcher->dispatched[1]))->toBe(['ip' => '192.0.2.4']); }); test('updatePassword returns true', function (): void { @@ -524,8 +577,7 @@ test('updatePassword returns true', function (): void { default => Mockery::mock()->shouldIgnoreMissing(), }); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->times(2); + $eventDispatcher = clientGuestTestEventDispatcher(); $passwordMock = Mockery::mock(FOSSBilling\PasswordManager::class); $passwordMock->shouldReceive('hashIt')->atLeast()->once(); @@ -538,15 +590,20 @@ test('updatePassword returns true', function (): void { $di = container(); $di['em'] = $em; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $di['password'] = $passwordMock; $di['mod_service'] = $di->protect(moduleService(['email' => $emailServiceMock, 'profile' => $profileServiceMock])); $guestClient->setDi($di); + $guestClient->setIp('192.0.2.5'); $result = $guestClient->update_password($data); expect($result)->toBeTrue(); + expect($eventDispatcher->dispatched)->toHaveCount(2); + expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Client\Event\BeforeClientPasswordResetConfirmationEvent('192.0.2.5')); + expect($eventDispatcher->dispatched[1])->toEqual(new Box\Mod\Client\Event\AfterClientPasswordResetEvent(1)); + expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.5']); }); test('updatePassword throws exception when reset not found', function (): void { @@ -557,17 +614,22 @@ test('updatePassword throws exception when reset not found', function (): void { 'password_confirm' => 'NewPassword1', ]; - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = clientGuestTestEventDispatcher(); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $guestClient->setDi($di); + $guestClient->setIp('192.0.2.6'); - $guestClient->update_password($data); -})->throws(FOSSBilling\Exception::class, 'The link has expired or you have already reset your password.'); + expect(fn () => $guestClient->update_password($data)) + ->toThrow(FOSSBilling\Exception::class, 'The link has expired or you have already reset your password.'); + + expect($eventDispatcher->dispatched)->toHaveCount(1); + expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Client\Event\BeforeClientPasswordResetConfirmationEvent('192.0.2.6')); + expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.6']); +}); test('required returns array', function (): void { $guestClient = apiEndpoint(new Box\Mod\Client\Api\Guest()); diff --git a/src/modules/Client/tests/Unit/ServiceTest.php b/src/modules/Client/tests/Unit/ServiceTest.php index f9015c933..34980cce3 100644 --- a/src/modules/Client/tests/Unit/ServiceTest.php +++ b/src/modules/Client/tests/Unit/ServiceTest.php @@ -10,6 +10,13 @@ declare(strict_types=1); +use Box\Mod\Client\Event\AfterAdminClientCreateEvent; +use Box\Mod\Client\Event\AfterClientSignUpEvent; +use Box\Mod\Client\Event\BeforeAdminClientCreateEvent; +use Box\Mod\Client\Event\BeforeClientPasswordResetEvent; +use Box\Mod\Client\Event\BeforeClientSignUpEvent; +use Box\Mod\Cron\Event\BeforeAdminCronRunEvent; + use function Tests\Helpers\container; use function Tests\Helpers\createEntity; use function Tests\Helpers\moduleService; @@ -22,6 +29,58 @@ test('getDi returns dependency injection container', function (): void { expect($getDi)->toEqual($di); }); +test('removes expired password reset requests on typed before cron event', function (): void { + $query = Mockery::mock(Doctrine\ORM\Query::class)->shouldIgnoreMissing(); + $query->shouldReceive('execute')->once()->andReturn(1); + + $queryBuilder = Mockery::mock(Doctrine\ORM\QueryBuilder::class)->shouldIgnoreMissing(); + $queryBuilder->shouldReceive('delete')->once()->andReturnSelf(); + $queryBuilder->shouldReceive('where')->once()->with('r.createdAt < :cutoff')->andReturnSelf(); + $queryBuilder->shouldReceive('setParameter')->once()->with('cutoff', Mockery::type(DateTime::class))->andReturnSelf(); + $queryBuilder->shouldReceive('getQuery')->once()->andReturn($query); + + $di = container(); + $passwordResetRepository = $di['em']->getRepository(Box\Mod\Client\Entity\ClientPasswordReset::class); + $passwordResetRepository->shouldReceive('createQueryBuilder')->once()->with('r')->andReturn($queryBuilder); + + $service = new Box\Mod\Client\Service(); + $service->setDi($di); + $service->removeExpiredPasswordResetRequests(new BeforeAdminCronRunEvent()); +}); + +test('password_reset_valid dispatches a safe event without exposing the reset hash', function (): void { + $service = new Box\Mod\Client\Service(); + $di = container(); + $repository = $di['em']->getRepository(Box\Mod\Client\Entity\ClientPasswordReset::class); + $repository->shouldReceive('findOneByHash')->once()->with('private-reset-hash')->andReturn(null); + + $events = []; + $eventDispatcher = new class($events) { + /** @param list $events */ + public function __construct(private array &$events) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; + + $di['request'] = Symfony\Component\HttpFoundation\Request::create('http://localhost/', server: ['REMOTE_ADDR' => '192.0.2.7']); + $di['event_dispatcher'] = $eventDispatcher; + $service->setDi($di); + + expect(fn () => $service->password_reset_valid(['hash' => 'private-reset-hash'])) + ->toThrow(FOSSBilling\InformationException::class, 'The link has expired or you have already reset your password.'); + + expect($events)->toHaveCount(1); + expect($events[0])->toEqual(new BeforeClientPasswordResetEvent('192.0.2.7')); + expect(get_object_vars($events[0]))->toBe(['ip' => '192.0.2.7']); +}); + test('approveClientEmailByHash returns true', function (): void { $service = new Box\Mod\Client\Service(); @@ -73,109 +132,62 @@ test('generateEmailConfirmationLink returns string', function (): void { expect(str_contains((string) $result, '/client/confirm-email/'))->toBeTrue(); }); -test('onAfterClientSignUp returns true', function (): void { +test('sendSignupEmail sends the client signup email', function (): void { $service = new Box\Mod\Client\Service(); - $eventParams = [ - 'password' => 'testPassword', - 'id' => 1, - ]; - - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn($eventParams); $emailService = Mockery::mock(Box\Mod\Email\Service::class); $emailService->shouldReceive('sendTemplate') - ->atLeast()->once() - ->andReturn(true); + ->once() + ->with([ + 'to_client' => 1, + 'code' => 'mod_client_signup', + 'require_email_confirmation' => false, + ]); $di = container(); $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $emailService); $di['mod_config'] = $di->protect(fn ($name): array => ['require_email_confirmation' => false]); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $service->setDi($di); - $result = $service->onAfterClientSignUp($eventMock); - - expect($result)->toBeTrue(); + $service->sendSignupEmail(new AfterClientSignUpEvent(1)); }); -test('onAfterClientSignUp with email confirmation required returns true', function (): void { - $service = new Box\Mod\Client\Service(); - $eventMock = Mockery::mock('\Box_Event'); - $eventParams = [ - 'password' => 'testPassword', - 'id' => 1, - 'require_email_confirmation' => true, - ]; - - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn($eventParams); +test('sendSignupEmail includes a confirmation link when required', function (): void { + $service = Mockery::mock(Box\Mod\Client\Service::class)->makePartial(); + $service->shouldReceive('generateEmailConfirmationLink')->once()->with(1)->andReturn('Link_string'); $emailService = Mockery::mock(Box\Mod\Email\Service::class); $emailService->shouldReceive('sendTemplate') - ->atLeast()->once() - ->andReturn(true); - - $clientServiceMock = Mockery::mock(Box\Mod\Client\Service::class)->makePartial(); - $clientServiceMock->shouldReceive('generateEmailConfirmationLink') - ->atLeast()->once() - ->andReturn('Link_string'); + ->once() + ->with([ + 'to_client' => 1, + 'code' => 'mod_client_signup', + 'require_email_confirmation' => true, + 'email_confirmation_link' => 'Link_string', + ]); $di = container(); - $di['mod_service'] = $di->protect(function ($serviceName) use ($emailService, $clientServiceMock) { - if ($serviceName == 'email') { - return $emailService; - } - if ($serviceName == 'client') { - return $clientServiceMock; - } - }); + $di['mod_service'] = $di->protect(fn ($serviceName) => $serviceName === 'email' ? $emailService : $service); $di['mod_config'] = $di->protect(fn ($name): array => ['require_email_confirmation' => true]); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $result = $service->onAfterClientSignUp($eventMock); - - expect($result)->toBeTrue(); + $service->setDi($di); + $service->sendSignupEmail(new AfterClientSignUpEvent(1)); }); -test('onAfterClientSignUp handles exception gracefully', function (): void { +test('sendSignupEmail handles email exceptions gracefully', function (): void { $service = new Box\Mod\Client\Service(); - $eventParams = [ - 'password' => 'testPassword', - 'id' => 1, - ]; - - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn($eventParams); $emailService = Mockery::mock(Box\Mod\Email\Service::class); $emailService->shouldReceive('sendTemplate') - ->atLeast()->once() + ->once() ->andThrow(new Exception('exception created in unit test')); $di = container(); $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $emailService); - $di['mod_config'] = $di->protect(function ($name): void { - ['require_email_confirmation' => false]; - }); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); + $di['mod_config'] = $di->protect(fn ($name): array => ['require_email_confirmation' => false]); $service->setDi($di); - $result = $service->onAfterClientSignUp($eventMock); - - expect($result)->toBeTrue(); + $service->sendSignupEmail(new AfterClientSignUpEvent(1)); }); dataset('searchQueryData', [ @@ -845,11 +857,23 @@ test('adminCreateClient returns int', function (): void { 'email' => 'test@unit.vm', 'first_name' => 'test', 'aid' => 'LEGACY-1001', + 'password_confirm' => 'do-not-expose', + 'send_welcome_email' => false, ]; - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire') - ->twice(); + $dispatched = []; + $eventDispatcher = new class($dispatched) { + public function __construct(private array &$dispatched) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->dispatched[] = $event; + + return $event; + } + }; $passwordMock = Mockery::mock(FOSSBilling\PasswordManager::class); $passwordMock->shouldReceive('hashIt') @@ -862,15 +886,88 @@ test('adminCreateClient returns int', function (): void { ->andReturn([]); $di = container(); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod'] = $di->protect(fn (): Mockery\MockInterface => $modMock); $di['password'] = $passwordMock; + $entityManager = $di['em']; + $entityManager->shouldReceive('persist')->atLeast()->once()->andReturnUsing(static function (object $entity): void { + if ($entity instanceof Box\Mod\Client\Entity\Client) { + (new ReflectionProperty(Box\Mod\Client\Entity\Client::class, 'id'))->setValue($entity, 42); + } + }); $service->setDi($di); $result = $service->adminCreateClient($data); expect($result)->toBeInt(); + expect($dispatched)->toHaveCount(2); + expect($dispatched[0])->toBeInstanceOf(BeforeAdminClientCreateEvent::class); + expect($dispatched[0]->input)->toBe([ + 'email' => 'test@unit.vm', + 'first_name' => 'test', + 'aid' => 'LEGACY-1001', + 'send_welcome_email' => false, + ]); + expect($dispatched[1])->toEqual(new AfterAdminClientCreateEvent(42)); +}); + +test('guestCreateClient dispatches sanitized signup input and the persisted client ID', function (): void { + $service = new Box\Mod\Client\Service(); + $data = [ + 'email' => 'test@unit.vm', + 'first_name' => 'test', + 'password' => 'StrongPass123', + 'password_confirm' => 'StrongPass123', + 'g-recaptcha-response' => 'captcha-token', + 'bio' => 'honeypot', + ]; + $dispatched = []; + $eventDispatcher = new class($dispatched) { + public function __construct(private array &$dispatched) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->dispatched[] = $event; + + return $event; + } + }; + + $di = container(); + $di['request'] = Symfony\Component\HttpFoundation\Request::create('http://localhost/', 'POST', server: ['REMOTE_ADDR' => '203.0.113.4']); + $di['event_dispatcher'] = $eventDispatcher; + $di['mod'] = $di->protect(static function (string $name): object { + $system = Mockery::mock(Box\Mod\System\Service::class); + $system->shouldReceive('getConfig')->once()->andReturn([]); + + return $system; + }); + $password = Mockery::mock(FOSSBilling\PasswordManager::class); + $password->shouldReceive('hashIt')->once()->with('StrongPass123')->andReturn('hashed'); + $di['password'] = $password; + $entityManager = $di['em']; + $entityManager->shouldReceive('persist')->once()->andReturnUsing(static function (object $entity): void { + expect($entity)->toBeInstanceOf(Box\Mod\Client\Entity\Client::class); + (new ReflectionProperty(Box\Mod\Client\Entity\Client::class, 'id'))->setValue($entity, 43); + }); + + $service->setDi($di); + $client = $service->guestCreateClient($data); + + expect($client->getId())->toBe(43); + expect($dispatched)->toHaveCount(2); + expect($dispatched[0])->toBeInstanceOf(BeforeClientSignUpEvent::class); + expect($dispatched[0]->input)->toBe([ + 'email' => 'test@unit.vm', + 'first_name' => 'test', + 'g-recaptcha-response' => 'captcha-token', + 'bio' => 'honeypot', + 'ip' => '203.0.113.4', + ]); + expect($dispatched[1])->toEqual(new AfterClientSignUpEvent(43)); }); test('deleteGroup returns true', function (): void { diff --git a/src/modules/Cron/Event/AfterAdminCronRunEvent.php b/src/modules/Cron/Event/AfterAdminCronRunEvent.php new file mode 100644 index 000000000..f32750f8e --- /dev/null +++ b/src/modules/Cron/Event/AfterAdminCronRunEvent.php @@ -0,0 +1,17 @@ +di['logger']->withChannel('cron')->info('Started executing cron jobs.'); // @core tasks - $this->_exec($api, 'hook_batch_connect'); - $this->di['events_manager']->fire(['event' => 'onBeforeAdminCronRun']); + $this->di['event_dispatcher']->dispatch(new BeforeAdminCronRunEvent()); $this->_exec($api, 'invoice_batch_pay_with_credits'); $this->_exec($api, 'invoice_batch_activate_paid'); @@ -118,7 +119,7 @@ class Service $count = $this->clearOldSessions() ?? 0; $this->di['logger']->withChannel('cron')->info("Cleared {$count} outdated sessions from the database."); - $this->di['events_manager']->fire(['event' => 'onAfterAdminCronRun']); + $this->di['event_dispatcher']->dispatch(new AfterAdminCronRunEvent()); if ($failedTasks !== []) { $this->di['logger']->withChannel('cron')->warning('Finished executing cron jobs, but the following tasks failed: ' . implode(', ', $failedTasks) . '.'); diff --git a/src/modules/Cron/tests/Unit/ServiceTest.php b/src/modules/Cron/tests/Unit/ServiceTest.php index 4369b852c..323030e5b 100644 --- a/src/modules/Cron/tests/Unit/ServiceTest.php +++ b/src/modules/Cron/tests/Unit/ServiceTest.php @@ -11,6 +11,7 @@ declare(strict_types=1); use Box\Mod\Cron\Service; +use FOSSBilling\Events\Event; use function Tests\Helpers\container; @@ -97,9 +98,6 @@ test('runCrons isolates failures in core batch tasks', function (string $failedT $updateFinalization = Mockery::mock(); $updateFinalization->shouldReceive('isRequired')->once()->andReturnFalse(); - $eventsManager = Mockery::mock('\\Box_EventManager'); - $eventsManager->shouldReceive('fire')->twice(); - $systemService = Mockery::mock(Box\Mod\System\Service::class); $systemService->shouldReceive('setParamValue') ->once() @@ -110,10 +108,21 @@ test('runCrons isolates failures in core batch tasks', function (string $failedT $api = new CronServiceApiDouble(); $api->throwOn = $failedTask; + $eventDispatcher = new class { + /** @var list */ + public array $events = []; + + public function dispatch(Event $event): Event + { + $this->events[] = $event; + + return $event; + } + }; $di = container(); $di['api_system'] = $api; $di['em']->shouldReceive('getConnection')->andReturn($connection); - $di['events_manager'] = $eventsManager; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $systemService); $di['update_finalization'] = $updateFinalization; @@ -131,7 +140,12 @@ test('runCrons isolates failures in core batch tasks', function (string $failedT ->toBeLessThan($positions['invoice_batch_send_reminders']) ->and($positions['invoice_batch_send_reminders']) ->toBeLessThan($positions['invoice_batch_invoke_due_event']) - ->and($api->methods)->toContain('email_batch_sendmail'); + ->and($api->methods)->toContain('email_batch_sendmail') + ->and(array_map(static fn (Event $event): string => $event::class, $eventDispatcher->events)) + ->toBe([ + Box\Mod\Cron\Event\BeforeAdminCronRunEvent::class, + Box\Mod\Cron\Event\AfterAdminCronRunEvent::class, + ]); })->with([ 'invoice generation' => 'invoice_batch_generate', 'invoice reminders' => 'invoice_batch_send_reminders', diff --git a/src/modules/Currency/Event/AfterAdminDeleteCurrencyEvent.php b/src/modules/Currency/Event/AfterAdminDeleteCurrencyEvent.php new file mode 100644 index 000000000..8af24be77 --- /dev/null +++ b/src/modules/Currency/Event/AfterAdminDeleteCurrencyEvent.php @@ -0,0 +1,20 @@ +di['events_manager']->fire(['event' => 'onBeforeAdminDeleteCurrency', 'params' => ['code' => $currencyCode]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminDeleteCurrencyEvent($currencyCode)); $em = $this->di['em']; $em->remove($currency); $em->flush(); - $this->di['events_manager']->fire(['event' => 'onAfterAdminDeleteCurrency', 'params' => ['code' => $currencyCode]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminDeleteCurrencyEvent($currencyCode)); $this->di['logger']->info('Removed currency {currency_code}.', ['currency_code' => $currency->getCode()]); @@ -747,19 +751,15 @@ class Service implements InjectionAwareInterface /** * If enabled, automatically call _getRate to fetch exchange rates whenever CRON jobs are run. */ - public static function onBeforeAdminCronRun(\Box_Event $event): bool + #[AsEventListener] + public function updateRatesBeforeAdminCronRun(BeforeAdminCronRunEvent $event): void { - $di = $event->getDi(); - $currencyService = $di['mod_service']('currency'); - try { - if ($currencyService->isCronEnabled()) { - $currencyService->updateCurrencyRates(); + if ($this->isCronEnabled()) { + $this->updateCurrencyRates(); } } catch (\Exception $e) { - $di['logger']->error($e->getMessage()); + $this->di['logger']->error($e->getMessage()); } - - return true; } } diff --git a/src/modules/Currency/tests/Unit/ServiceTest.php b/src/modules/Currency/tests/Unit/ServiceTest.php index 33cc97331..73ef04b70 100644 --- a/src/modules/Currency/tests/Unit/ServiceTest.php +++ b/src/modules/Currency/tests/Unit/ServiceTest.php @@ -10,6 +10,10 @@ declare(strict_types=1); +use Box\Mod\Currency\Event\AfterAdminDeleteCurrencyEvent; +use Box\Mod\Currency\Event\BeforeAdminDeleteCurrencyEvent; +use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher; + use function Tests\Helpers\container; test('di returns dependency injection container', function (): void { @@ -391,19 +395,30 @@ test('removeCurrency removes currency', function (): void { $emMock->shouldReceive('getRepository') ->atLeast()->once() ->andReturn($repositoryMock); - $emMock->shouldReceive('remove') - ->atLeast()->once() - ->with($model); - $emMock->shouldReceive('flush') - ->atLeast()->once(); + $events = new ArrayObject(); + $eventDispatcher = new SymfonyEventDispatcher(); + foreach ([BeforeAdminDeleteCurrencyEvent::class, AfterAdminDeleteCurrencyEvent::class] as $eventClass) { + $eventDispatcher->addListener($eventClass, static function (object $event) use ($events): void { + $events->append($event); + }); + } - $eventsManager = Mockery::mock(Box_EventManager::class); - $eventsManager->shouldReceive('fire') - ->twice(); + $emMock->shouldReceive('remove') + ->once() + ->with($model) + ->andReturnUsing(function () use ($events): void { + expect($events->getArrayCopy())->toHaveCount(1); + expect($events[0])->toBeInstanceOf(BeforeAdminDeleteCurrencyEvent::class); + }); + $emMock->shouldReceive('flush') + ->once() + ->andReturnUsing(function () use ($events): void { + expect($events->getArrayCopy())->toHaveCount(1); + }); $di = new Pimple\Container(); $di['em'] = $emMock; - $di['events_manager'] = $eventsManager; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $service = new Box\Mod\Currency\Service(); @@ -411,6 +426,10 @@ test('removeCurrency removes currency', function (): void { $result = $service->removeCurrency('EUR'); expect($result)->toBeTrue(); + expect(array_map(static fn (object $event): string => $event::class, $events->getArrayCopy())) + ->toBe([BeforeAdminDeleteCurrencyEvent::class, AfterAdminDeleteCurrencyEvent::class]); + expect($events[0]->code)->toBe('EUR'); + expect($events[1]->code)->toBe('EUR'); }); test('removeCurrency throws exception when currency is not found', function (): void { @@ -770,6 +789,18 @@ test('updateCurrencyRates preserves manual overrides', function (): void { expect($service->updateCurrencyRates())->toBeTrue(); }); +test('updates currency rates before admin cron through a typed event listener', function (): void { + $service = Mockery::mock(Box\Mod\Currency\Service::class)->makePartial(); + $service->shouldReceive('isCronEnabled')->once()->andReturn(true); + $service->shouldReceive('updateCurrencyRates')->once()->andReturn(true); + $dispatcher = new FOSSBilling\Events\EventDispatcher( + static fn (): array => ['currency'], + static fn (string $module): object => $service, + ); + + $dispatcher->dispatch(new Box\Mod\Cron\Event\BeforeAdminCronRunEvent()); +}); + test('removeCurrency deletes currency by code', function (): void { $model = Mockery::mock(Box\Mod\Currency\Entity\Currency::class); $model->shouldReceive('getCode') @@ -795,15 +826,10 @@ test('removeCurrency deletes currency by code', function (): void { $emMock->shouldReceive('flush') ->atLeast()->once(); - $manager = Mockery::mock('Box_EventManager'); - $manager->shouldReceive('fire') - ->atLeast()->once() - ->andReturn(true); - $di = new Pimple\Container(); $di['logger'] = new Tests\Helpers\TestLogger(); $di['em'] = $emMock; - $di['events_manager'] = $manager; + $di['event_dispatcher'] = new FOSSBilling\Events\EventDispatcher(static fn (): array => [], static fn (string $module): object => new stdClass()); $service = new Box\Mod\Currency\Service(); $service->setDi($di); diff --git a/src/modules/Email/Api/Admin.php b/src/modules/Email/Api/Admin.php index 9e4d37070..43e804f08 100644 --- a/src/modules/Email/Api/Admin.php +++ b/src/modules/Email/Api/Admin.php @@ -204,7 +204,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi /** * Create new email template. Creating new email template can be - * combined with custom event hook. + * combined with a typed event listener. * * @return int - newly created template id * diff --git a/src/modules/Email/templates/admin/mod_email_settings.html.twig b/src/modules/Email/templates/admin/mod_email_settings.html.twig index 541aa7d95..2a489b8b4 100644 --- a/src/modules/Email/templates/admin/mod_email_settings.html.twig +++ b/src/modules/Email/templates/admin/mod_email_settings.html.twig @@ -592,7 +592,7 @@ - - {% if can_manage_hooks %} -
-
-

{{ 'Registered hooks let extensions integrate with key lifecycle events throughout FOSSBilling.'|trans }}

-
-
- - - - - {{ include('partial_sortable_th.html.twig', { 'label': 'Hook'|trans, 'sort_key': 'event', 'url': 'extension', 'hash': '#tab-hooks', 'default_sort': 'id', 'default_direction': 'ASC' }) }} - - - - {% set hooks = admin.hook_get_list({ 'per_page': 25, 'page': request.page }|merge(request)) %} - {% for hook in hooks.list %} - - - - - {% endfor %} - -
{{ 'Extension'|trans }}
- {{ hook.rel_id|capitalize }} - - {{ hook.event }} -
-
- -
- {% endif %} diff --git a/src/modules/Extension/tests/Unit/Api/AdminTest.php b/src/modules/Extension/tests/Unit/Api/AdminTest.php index 6fa1b9ab1..0e6416f29 100644 --- a/src/modules/Extension/tests/Unit/Api/AdminTest.php +++ b/src/modules/Extension/tests/Unit/Api/AdminTest.php @@ -10,7 +10,21 @@ declare(strict_types=1); +use Box\Mod\Extension\Entity\Extension; +use Box\Mod\Extension\Event\AfterAdminDeactivateExtensionEvent; +use Box\Mod\Extension\Event\AfterAdminInstallExtensionEvent; +use Box\Mod\Extension\Event\AfterAdminUninstallExtensionEvent; +use Box\Mod\Extension\Event\AfterAdminUpdateExtensionEvent; +use Box\Mod\Extension\Event\BeforeAdminDeactivateExtensionEvent; +use Box\Mod\Extension\Event\BeforeAdminInstallExtensionEvent; +use Box\Mod\Extension\Event\BeforeAdminUninstallExtensionEvent; +use Box\Mod\Extension\Event\BeforeAdminUpdateExtensionEvent; +use Box\Mod\Extension\Repository\ExtensionRepository; +use Box\Mod\Extension\Service; +use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher; + use function Tests\Helpers\container; +use function Tests\Helpers\setEntityId; test('getDi returns the dependency injection container', function (): void { $api = apiEndpoint(new Box\Mod\Extension\Api\Admin()); @@ -22,7 +36,7 @@ test('getDi returns the dependency injection container', function (): void { test('activate activates an extension', function (): void { $api = apiEndpoint(new Box\Mod\Extension\Api\Admin()); - $serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial(); + $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldAllowMockingProtectedMethods(); $serviceMock->shouldReceive('activateExistingExtension') ->atLeast() @@ -35,9 +49,165 @@ test('activate activates an extension', function (): void { expect($result)->toBeArray(); }); +test('install dispatches typed lifecycle events with only extension identity', function (): void { + $api = apiEndpoint(new Box\Mod\Extension\Api\Admin()); + $steps = []; + $service = Mockery::mock(Service::class); + $service->shouldReceive('downloadAndExtract') + ->once() + ->with('mod', 'example') + ->andReturnUsing(function () use (&$steps): bool { + $steps[] = 'download'; + + return true; + }); + $dispatcher = new SymfonyEventDispatcher(); + $dispatcher->addListener(BeforeAdminInstallExtensionEvent::class, static function (BeforeAdminInstallExtensionEvent $event) use (&$steps): void { + $steps[] = $event; + }); + $dispatcher->addListener(AfterAdminInstallExtensionEvent::class, static function (AfterAdminInstallExtensionEvent $event) use (&$steps): void { + $steps[] = $event; + }); + + $di = $api->getDi(); + $di['event_dispatcher'] = $dispatcher; + $api->setDi($di); + $api->setService($service); + + $result = $api->install(['id' => 'example', 'type' => 'mod', 'api_key' => 'secret']); + expect($result)->toBe(['success' => true, 'id' => 'example', 'type' => 'mod']) + ->and($steps)->toHaveCount(3) + ->and($steps[0])->toBeInstanceOf(BeforeAdminInstallExtensionEvent::class) + ->and($steps[0]->extensionType)->toBe('mod') + ->and($steps[0]->extensionName)->toBe('example') + ->and($steps[1])->toBe('download') + ->and($steps[2])->toBeInstanceOf(AfterAdminInstallExtensionEvent::class); +}); + +test('uninstall dispatches typed lifecycle events around service cleanup', function (): void { + $api = apiEndpoint(new Box\Mod\Extension\Api\Admin()); + $steps = []; + $service = Mockery::mock(Service::class); + $service->shouldReceive('uninstall') + ->once() + ->with('mod', 'example') + ->andReturnUsing(function () use (&$steps): bool { + $steps[] = 'uninstall'; + + return true; + }); + $dispatcher = new SymfonyEventDispatcher(); + $dispatcher->addListener(BeforeAdminUninstallExtensionEvent::class, static function (BeforeAdminUninstallExtensionEvent $event) use (&$steps): void { + $steps[] = $event; + }); + $dispatcher->addListener(AfterAdminUninstallExtensionEvent::class, static function (AfterAdminUninstallExtensionEvent $event) use (&$steps): void { + $steps[] = $event; + }); + + $di = $api->getDi(); + $di['event_dispatcher'] = $dispatcher; + $api->setDi($di); + $api->setService($service); + + expect($api->uninstall(['id' => 'example', 'type' => 'mod']))->toBeTrue() + ->and($steps)->toHaveCount(3) + ->and($steps[0])->toBeInstanceOf(BeforeAdminUninstallExtensionEvent::class) + ->and($steps[0]->extensionType)->toBe('mod') + ->and($steps[0]->extensionName)->toBe('example') + ->and($steps[1])->toBe('uninstall') + ->and($steps[2])->toBeInstanceOf(AfterAdminUninstallExtensionEvent::class); +}); + +test('deactivate dispatches typed events around service deactivation', function (): void { + $api = apiEndpoint(new Box\Mod\Extension\Api\Admin()); + $extension = new Extension(); + setEntityId($extension, 52); + $extension->setType('mod'); + $extension->setName('example'); + $repository = Mockery::mock(ExtensionRepository::class); + $repository->shouldReceive('findOneByTypeAndName') + ->once() + ->with('mod', 'example') + ->andReturn($extension); + + $steps = []; + $service = Mockery::mock(Service::class); + $service->shouldReceive('getExtensionRepository')->once()->andReturn($repository); + $service->shouldReceive('deactivate') + ->once() + ->with($extension) + ->andReturnUsing(function () use (&$steps): bool { + $steps[] = 'deactivate'; + + return true; + }); + $dispatcher = new SymfonyEventDispatcher(); + $dispatcher->addListener(BeforeAdminDeactivateExtensionEvent::class, static function (BeforeAdminDeactivateExtensionEvent $event) use (&$steps): void { + $steps[] = $event; + }); + $dispatcher->addListener(AfterAdminDeactivateExtensionEvent::class, static function (AfterAdminDeactivateExtensionEvent $event) use (&$steps): void { + $steps[] = $event; + }); + + $di = $api->getDi(); + $di['event_dispatcher'] = $dispatcher; + $api->setDi($di); + $api->setService($service); + + expect($api->deactivate(['id' => 'example', 'type' => 'mod']))->toBeTrue() + ->and($steps)->toHaveCount(3) + ->and($steps[0])->toBeInstanceOf(BeforeAdminDeactivateExtensionEvent::class) + ->and($steps[0]->extensionRecordId)->toBe(52) + ->and($steps[0]->extensionType)->toBe('mod') + ->and($steps[0]->extensionName)->toBe('example') + ->and($steps[1])->toBe('deactivate') + ->and($steps[2])->toBeInstanceOf(AfterAdminDeactivateExtensionEvent::class) + ->and($steps[2]->extensionRecordId)->toBe(52); +}); + +test('update dispatches the typed before event before the current unsupported update failure', function (): void { + $api = apiEndpoint(new Box\Mod\Extension\Api\Admin()); + $extension = new Extension(); + setEntityId($extension, 53); + $extension->setType('mod'); + $extension->setName('example'); + $repository = Mockery::mock(ExtensionRepository::class); + $repository->shouldReceive('findOneByTypeAndName') + ->once() + ->with('mod', 'example') + ->andReturn($extension); + + $steps = []; + $service = Mockery::mock(Service::class)->makePartial(); + $service->shouldReceive('getExtensionRepository')->once()->andReturn($repository); + $service->shouldReceive('update') + ->once() + ->with($extension) + ->andThrow(new FOSSBilling\InformationException('Update is unavailable')); + $dispatcher = new SymfonyEventDispatcher(); + $dispatcher->addListener(BeforeAdminUpdateExtensionEvent::class, static function (BeforeAdminUpdateExtensionEvent $event) use (&$steps): void { + $steps[] = $event; + }); + $dispatcher->addListener(AfterAdminUpdateExtensionEvent::class, static function (AfterAdminUpdateExtensionEvent $event) use (&$steps): void { + $steps[] = $event; + }); + + $di = $api->getDi(); + $di['event_dispatcher'] = $dispatcher; + $api->setDi($di); + $api->setService($service); + + expect(fn () => $api->update(['id' => 'example', 'type' => 'mod'])) + ->toThrow(FOSSBilling\InformationException::class) + ->and($steps)->toHaveCount(1) + ->and($steps[0])->toBeInstanceOf(BeforeAdminUpdateExtensionEvent::class) + ->and($steps[0]->extensionRecordId)->toBe(53) + ->and($steps[0]->extensionName)->toBe('example'); +}); + test('configGet gets extension config', function (): void { $api = apiEndpoint(new Box\Mod\Extension\Api\Admin()); - $serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial(); + $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldAllowMockingProtectedMethods(); $serviceMock->shouldReceive('getConfig') ->atLeast() @@ -59,7 +229,7 @@ test('configGet gets extension config', function (): void { test('configSave saves extension config', function (): void { $api = apiEndpoint(new Box\Mod\Extension\Api\Admin()); - $serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial(); + $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldAllowMockingProtectedMethods(); $serviceMock->shouldReceive('hasManagePermission') ->with('mod_example') @@ -79,7 +249,7 @@ test('configSave saves extension config', function (): void { test('getList returns extensions list', function (): void { $api = apiEndpoint(new Box\Mod\Extension\Api\Admin()); - $serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial(); + $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldAllowMockingProtectedMethods(); $serviceMock->shouldReceive('getExtensionsList') ->atLeast() @@ -94,7 +264,7 @@ test('getList returns extensions list', function (): void { test('getNavigation returns admin navigation', function (): void { $api = apiEndpoint(new Box\Mod\Extension\Api\Admin()); - $serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial(); + $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldAllowMockingProtectedMethods(); $serviceMock->shouldReceive('getAdminNavigation') ->atLeast() diff --git a/src/modules/Extension/tests/Unit/ServiceTest.php b/src/modules/Extension/tests/Unit/ServiceTest.php index ba6b5e087..1e14d5383 100644 --- a/src/modules/Extension/tests/Unit/ServiceTest.php +++ b/src/modules/Extension/tests/Unit/ServiceTest.php @@ -12,9 +12,18 @@ declare(strict_types=1); use Box\Mod\Extension\Entity\Extension; use Box\Mod\Extension\Entity\ExtensionMeta; +use Box\Mod\Extension\Event\AfterAdminActivateExtensionEvent; +use Box\Mod\Extension\Event\AfterAdminExtensionConfigSaveEvent; +use Box\Mod\Extension\Event\AfterExtensionActivatedEvent; +use Box\Mod\Extension\Event\AfterExtensionDeactivatedEvent; +use Box\Mod\Extension\Event\BeforeAdminActivateExtensionEvent; +use Box\Mod\Extension\Event\BeforeAdminExtensionConfigSaveEvent; use Box\Mod\Extension\Repository\ExtensionMetaRepository; use Box\Mod\Extension\Repository\ExtensionRepository; use Box\Mod\Extension\Service; +use Box\Mod\Widgets\Service as WidgetsService; +use FOSSBilling\Events\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher; use function Tests\Helpers\container; use function Tests\Helpers\setEntityId; @@ -374,13 +383,41 @@ test('activate activates an extension', function (): void { ->atLeast() ->once(); + $activeModules = []; + $flushes = 0; $em = extensionBuildEm(); - $em->shouldReceive('flush')->atLeast()->once(); + $em->shouldReceive('flush')->twice()->andReturnUsing(function () use (&$activeModules, &$flushes): void { + ++$flushes; + if ($flushes === 2) { + $activeModules = ['widgets']; + } + }); $di = container(); $di['em'] = $em; $di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock); $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService); + $cache = new class { + public array $deleted = []; + + public function delete(string $key): bool + { + $this->deleted[] = $key; + + return true; + } + }; + $di['cache'] = $cache; + $widgets = new WidgetsService(); + $widgets->setDi($di); + $dispatcher = new EventDispatcher( + static function () use (&$activeModules): array { + return $activeModules; + }, + static fn (string $module): object => $widgets, + ); + $dispatcher->dispatch(new AfterExtensionActivatedEvent(0, 'theme', 'warmup')); + $di['event_dispatcher'] = $dispatcher; $service->setDi($di); $result = $service->activate($ext); @@ -389,11 +426,12 @@ test('activate activates an extension', function (): void { expect($result['type'])->toBe('mod'); expect($result['redirect'])->toBeTrue(); expect($result['has_settings'])->toBeTrue(); + expect($cache->deleted)->toBe([WidgetsService::CACHE_KEY]); }); test('deactivate deactivates an extension', function (): void { $service = new Service(); - $ext = extensionCreateEntity(1, 'mod', 'extensionTest', 'installed'); + $ext = extensionCreateEntity(1, 'mod', 'widgets', 'installed'); $modMock = Mockery::mock(FOSSBilling\Module::class); $modMock->shouldReceive('getCoreModules') @@ -404,19 +442,44 @@ test('deactivate deactivates an extension', function (): void { $staffService = Mockery::mock(Box\Mod\Staff\Service::class); $staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once(); + $activeModules = ['widgets']; $em = extensionBuildEm(); $em->shouldReceive('remove')->atLeast()->once(); - $em->shouldReceive('flush')->atLeast()->once(); + $em->shouldReceive('flush')->once()->andReturnUsing(function () use (&$activeModules): void { + $activeModules = []; + }); $di = container(); $di['em'] = $em; $di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock); $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService); + $cache = new class { + public array $deleted = []; + + public function delete(string $key): bool + { + $this->deleted[] = $key; + + return true; + } + }; + $di['cache'] = $cache; + $widgets = new WidgetsService(); + $widgets->setDi($di); + $dispatcher = new EventDispatcher( + static function () use (&$activeModules): array { + return $activeModules; + }, + static fn (string $module): object => $widgets, + ); + $dispatcher->dispatch(new AfterExtensionDeactivatedEvent(0, 'theme', 'warmup')); + $di['event_dispatcher'] = $dispatcher; $service->setDi($di); $result = $service->deactivate($ext); expect($result)->toBeTrue(); + expect($cache->deleted)->toBeEmpty(); }); test('deactivate throws exception for core modules', function (): void { @@ -445,31 +508,6 @@ test('deactivate throws exception for core modules', function (): void { ->toThrow(FOSSBilling\Exception::class, 'Core modules are an integral part of the FOSSBilling system and cannot be deactivated.'); }); -test('deactivate deactivates hook extension', function (): void { - $ext = extensionCreateEntity(1, 'hook', 'extensionTest', 'installed'); - - $staffService = Mockery::mock(Box\Mod\Staff\Service::class); - $staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once(); - - $filesystemMock = Mockery::mock(Symfony\Component\Filesystem\Filesystem::class); - $filesystemMock->shouldReceive('exists')->atLeast()->once()->andReturn(true); - $filesystemMock->shouldReceive('remove')->atLeast()->once(); - - $service = new Service($filesystemMock); - - $em = extensionBuildEm(); - $em->shouldReceive('remove')->atLeast()->once(); - $em->shouldReceive('flush')->atLeast()->once(); - - $di = container(); - $di['em'] = $em; - $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService); - - $service->setDi($di); - $result = $service->deactivate($ext); - expect($result)->toBeTrue(); -}); - test('deactivate deactivates module', function (): void { $ext = extensionCreateEntity(1, 'mod', 'extensionTest', 'installed'); @@ -621,35 +659,51 @@ test('activateExistingExtension activates existing extension', function (): void 'type' => 'extensionType', ]; + $steps = []; $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldAllowMockingProtectedMethods(); $serviceMock->shouldReceive('activate') - ->atLeast() ->once() - ->andReturn([]); + ->andReturnUsing(function () use (&$steps): array { + $steps[] = 'activate'; + return []; + }); + + $extension = extensionCreateEntity(42, 'mod', 'extensionId', Extension::STATUS_DEACTIVATED); $extensionRepository = Mockery::mock(ExtensionRepository::class); $extensionRepository->shouldReceive('findOneByTypeAndName') - ->atLeast() ->once() - ->andReturn(null); + ->with('extensionType', 'extensionId') + ->andReturn($extension); $em = extensionBuildEm($extensionRepository); - $em->shouldReceive('persist')->atLeast()->once(); - $em->shouldReceive('flush')->atLeast()->once(); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $dispatcher = new SymfonyEventDispatcher(); + $dispatcher->addListener(BeforeAdminActivateExtensionEvent::class, static function (BeforeAdminActivateExtensionEvent $event) use (&$steps): void { + $steps[] = $event; + }); + $dispatcher->addListener(AfterAdminActivateExtensionEvent::class, static function (AfterAdminActivateExtensionEvent $event) use (&$steps): void { + $steps[] = $event; + }); $di = container(); $di['em'] = $em; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $dispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); $result = $serviceMock->activateExistingExtension($data); - expect($result)->toBeArray(); + expect($result)->toBeArray() + ->and($steps)->toHaveCount(3) + ->and($steps[0])->toBeInstanceOf(BeforeAdminActivateExtensionEvent::class) + ->and($steps[0]->extensionRecordId)->toBe(42) + ->and($steps[0]->extensionType)->toBe('mod') + ->and($steps[0]->extensionName)->toBe('extensionId') + ->and($steps[1])->toBe('activate') + ->and($steps[2])->toBeInstanceOf(AfterAdminActivateExtensionEvent::class) + ->and($steps[2]->extensionRecordId)->toBe(42); }); test('activateExistingExtension requires type and id', function (): void { @@ -685,12 +739,8 @@ test('activateExistingExtension accepts zero-like identifiers', function (): voi ->once() ->andReturn([]); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); - $di = container(); $di['em'] = $em; - $di['events_manager'] = $eventMock; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); @@ -719,19 +769,28 @@ test('activateExistingExtension throws exception on activation failure', functio ->once() ->andReturn($model); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $events = []; + $dispatcher = new SymfonyEventDispatcher(); + $dispatcher->addListener(BeforeAdminActivateExtensionEvent::class, static function (BeforeAdminActivateExtensionEvent $event) use (&$events): void { + $events[] = $event; + }); + $dispatcher->addListener(AfterAdminActivateExtensionEvent::class, static function (AfterAdminActivateExtensionEvent $event) use (&$events): void { + $events[] = $event; + }); $em = extensionBuildEm($extensionRepository); $di = container(); $di['em'] = $em; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $dispatcher; $serviceMock->setDi($di); expect(fn () => $serviceMock->activateExistingExtension($data)) - ->toThrow(Exception::class); + ->toThrow(Exception::class) + ->and($events)->toHaveCount(1) + ->and($events[0])->toBeInstanceOf(BeforeAdminActivateExtensionEvent::class) + ->and($events[0]->extensionRecordId)->toBe(1); }); test('getConfig returns extension config', function (): void { @@ -794,6 +853,10 @@ test('getConfig creates new ExtensionMeta when not found', function (): void { test('setConfig sets extension config', function (): void { $data = [ 'ext' => 'extensionName', + 'username' => 'admin', + 'api_key' => 'secret-api-key', + 'password' => 'secret-password', + 'nested' => ['credential' => 'nested-secret'], ]; $serviceMock = Mockery::mock(Service::class)->makePartial(); @@ -811,10 +874,15 @@ test('setConfig sets extension config', function (): void { $toolsMock = Mockery::mock(FOSSBilling\Tools::class); $cryptMock = Mockery::mock(FOSSBilling\Crypt::class); + $encryptedConfig = null; $cryptMock->shouldReceive('encrypt') - ->atLeast() ->once() - ->andReturn('encryptedConfig'); + ->with(json_encode($data), Mockery::type('string')) + ->andReturnUsing(function (string $config, string $salt) use (&$encryptedConfig): string { + $encryptedConfig = $config; + + return 'encryptedConfig'; + }); $metaRepo = Mockery::mock(ExtensionMetaRepository::class); $metaRepo->shouldReceive('findOneByExtensionAndScope') @@ -824,23 +892,40 @@ test('setConfig sets extension config', function (): void { $em = extensionBuildEm(null, $metaRepo); $em->shouldReceive('persist')->atLeast()->once(); - $em->shouldReceive('flush')->atLeast()->once(); - - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $flushed = false; + $em->shouldReceive('flush')->once()->andReturnUsing(function () use (&$flushed): void { + $flushed = true; + }); + $events = []; + $dispatcher = new SymfonyEventDispatcher(); + $dispatcher->addListener(BeforeAdminExtensionConfigSaveEvent::class, static function (BeforeAdminExtensionConfigSaveEvent $event) use (&$events): void { + $events[] = [$event, false]; + }); + $dispatcher->addListener(AfterAdminExtensionConfigSaveEvent::class, static function (AfterAdminExtensionConfigSaveEvent $event) use (&$events, &$flushed): void { + $events[] = [$event, $flushed]; + }); $di = container(); $di['em'] = $em; $di['tools'] = $toolsMock; $di['crypt'] = $cryptMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $dispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $di['cache'] = new Symfony\Component\Cache\Adapter\ArrayAdapter(); $serviceMock->setDi($di); $result = $serviceMock->setConfig($data); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($encryptedConfig)->toBe(json_encode($data)) + ->and($events)->toHaveCount(2) + ->and($events[0][0])->toBeInstanceOf(BeforeAdminExtensionConfigSaveEvent::class) + ->and($events[0][0]->extensionName)->toBe('extensionName') + ->and($events[0][0]->configurationKeys)->toBe(['api_key', 'nested', 'password', 'username']) + ->and($events[0][1])->toBeFalse() + ->and($events[1][0])->toBeInstanceOf(AfterAdminExtensionConfigSaveEvent::class) + ->and($events[1][0]->configurationKeys)->toBe(['api_key', 'nested', 'password', 'username']) + ->and($events[1][1])->toBeTrue(); }); test('hasManagePermission denies access when module does not declare manage_settings', function (): void { @@ -1042,3 +1127,10 @@ test('setConfig denies inactive module configuration without touching storage wh expect(fn () => $serviceMock->setConfig(['ext' => 'mod_support'])) ->toThrow(new FOSSBilling\InformationException('You need the "extension.manage_extensions" permission to perform this action', [], 403)); }); + +test('the typed cron listener refreshes the extension list', function (): void { + $service = Mockery::mock(Service::class)->makePartial(); + $service->shouldReceive('getExtensionsList')->once()->with([])->andReturn([]); + + $service->refreshExtensionsOnCron(new Box\Mod\Cron\Event\BeforeAdminCronRunEvent()); +}); diff --git a/src/modules/Hook/Api/Admin.php b/src/modules/Hook/Api/Admin.php deleted file mode 100644 index 77f325b0b..000000000 --- a/src/modules/Hook/Api/Admin.php +++ /dev/null @@ -1,90 +0,0 @@ -checkPermissions('hook', 'view'); - - $service = $this->getService(); - [$sql, $params] = $service->getSearchQuery($data); - - return $this->getDi()['pager']->getPaginatedResultSet($sql, $params, PaginationOptions::fromArray($data)); - } - - /** - * Invoke hook with params. - * - * @optional array $params - what params are passed to event method $event->getParams() - * - * @return mixed - event return value - */ - public function call($data) - { - $this->checkPermissions('hook', 'trigger_hooks'); - - if (!isset($data['event']) || empty($data['event'])) { - $this->getDi()['logger']->warning('Invoked event call without providing event name'); - - return false; - } - - $event = $data['event']; - $params = $data['params'] ?? null; - // @phpstan-ignore if.alwaysFalse - if (DEBUG) { - try { - $this->getDi()['logger']->info($event . ': ' . var_export($params, true)); - } catch (\Throwable $e) { - // The application logger failed; retain a PHP fallback rather than - // masking hook dispatch with a logging failure. - error_log('[Hook] logger failure: ' . $e->getMessage()); - } - } - - return $this->getDi()['events_manager']->fire($data); - } - - /** - * Reinstall and activate all existing hooks from module or all - * activated modules. Does not connect already connected event. - * - * @optional string $mod - module name to connect hooks - * - * @return bool - */ - public function batch_connect($data) - { - $this->checkPermissions('hook', 'manage_hooks'); - - $mod = $data['mod'] ?? null; - $service = $this->getService(); - - return $service->batchConnect($mod); - } -} diff --git a/src/modules/Hook/Service.php b/src/modules/Hook/Service.php deleted file mode 100644 index 119c2b57c..000000000 --- a/src/modules/Hook/Service.php +++ /dev/null @@ -1,308 +0,0 @@ -di = $di; - } - - public function getDi(): ?\Pimple\Container - { - return $this->di; - } - - public function getModulePermissions(): array - { - return [ - 'view' => [ - 'type' => 'bool', - 'display_name' => __trans('View hooks'), - 'description' => __trans('Allows the staff member to view registered event hooks.'), - ], - 'manage_hooks' => [ - 'type' => 'bool', - 'display_name' => __trans('Manage hooks'), - 'description' => __trans('Allows the staff member to reconnect registered event hooks.'), - ], - 'trigger_hooks' => [ - 'type' => 'bool', - 'display_name' => __trans('Trigger hooks'), - 'description' => __trans('Allows the staff member to invoke hooks manually with custom event payloads.'), - ], - ]; - } - - public function getSearchQuery($filter): array - { - $q = "SELECT id, rel_type, rel_id, meta_value as event, created_at, updated_at - FROM extension_meta - WHERE extension = 'mod_hook' - AND rel_type = 'mod' - AND meta_key = 'listener' - "; - - $sort = SortOptions::fromArray(is_array($filter) ? $filter : [], [ - 'id' => 'id', - 'event' => 'meta_value', - 'created_at' => 'created_at', - 'updated_at' => 'updated_at', - ]); - $orderBy = $sort->toOrderByClause('id') ?? 'id ASC'; - $q .= " ORDER BY {$orderBy}"; - - return [$q, []]; - } - - /** - * Whether any module event listener has ever been connected. - * - * Listeners are normally (re)connected by the cron job's hook_batch_connect task. - * Before cron has run for the first time (e.g. right after a fresh install), this - * is false and every event fired by the application silently has no listeners. - */ - public function hasConnectedListeners(): bool - { - $q = "SELECT 1 - FROM extension_meta - WHERE extension = 'mod_hook' - AND rel_type = 'mod' - AND meta_key = 'listener' - LIMIT 1 - "; - - return (bool) $this->di['em']->getConnection()->fetchOne($q); - } - - public function toApiArray($row) - { - return $row; - } - - public static function onAfterAdminActivateExtension(\Box_Event $event): void - { - $params = $event->getParameters(); - if (!isset($params['id'])) { - $event->setReturnValue(false); - } else { - $di = $event->getDi(); - $ext = $di['em']->getRepository(Extension::class)->find((int) $params['id']); - if ($ext !== null && $ext->getType() === Extension::TYPE_MOD) { - $service = $di['mod_service']('hook'); - $service->batchConnect($ext->getName()); - } - $event->setReturnValue(true); - } - } - - public static function onAfterAdminDeactivateExtension(\Box_Event $event): void - { - $di = $event->getDi(); - $params = $event->getParameters(); - if ($params['type'] == 'mod') { - $q = "DELETE FROM extension_meta - WHERE extension = 'mod_hook' - AND rel_type = 'mod' - AND rel_id = :mod - AND meta_key = 'listener'"; - $di['em']->getConnection()->executeStatement($q, ['mod' => $params['id']]); - $di['events_manager']->clearListenerCache(); - } - - $event->setReturnValue(true); - } - - /** - * Serializes batchConnect() runs so two concurrent callers (e.g. overlapping cron and - * on-demand rebuilds triggered by a login) cannot interleave connect()'s check-then-insert - * and create duplicate listener rows. The rebuild itself runs in a single DB transaction, - * so hasConnectedListeners() can only ever observe the previous complete set or the new - * complete set, never a partially rebuilt one. - * - * Returns false if the lock could not be acquired within the timeout, so a failure to - * initialize is never mistaken for success - callers that need listeners connected before - * proceeding should check the return value rather than assume this always succeeds. - */ - public function batchConnect($mod_name = null): bool - { - $connection = $this->di['em']->getConnection(); - if (!NamedLock::acquire($connection, self::BATCH_CONNECT_LOCK, 5)) { - // Another process is already rebuilding the listener set and holding the lock - // past our wait. Report failure rather than claiming a rebuild we didn't run or - // wait for actually completed. - return false; - } - - try { - $connection->transactional(function () use ($mod_name): void { - // Clean up the existing list before we add to it - $this->_disconnectUnavailable(); - $extensionService = $this->di['mod_service']('extension'); - - $mods = []; - if ($mod_name !== null) { - $mods[] = $mod_name; - } else { - $mods = $extensionService->getCoreAndActiveModules(); - } - - foreach ($mods as $m) { - $installed = $this->getExtensionRepository()->existsActiveByTypeAndName(Extension::TYPE_MOD, $m); - if (!$installed && !$extensionService->isCoreModule($m)) { - continue; - } - - $mod = $this->di['mod']($m); - if ($mod->hasService()) { - $class = $mod->getService(); - $reflector = new \ReflectionClass($class); - foreach ($reflector->getMethods() as $method) { - if ($this->canBeConnected($method)) { - $this->connect(['event' => $method->getName(), 'mod' => $mod->getName()]); - } - } - } - } - }); - - $this->di['events_manager']->clearListenerCache(); - - return true; - } finally { - NamedLock::release($connection, self::BATCH_CONNECT_LOCK); - } - } - - private function canBeConnected(\ReflectionMethod $method): bool - { - $parameters = $method->getParameters(); - if (!isset($parameters[0]) || !$method->isPublic()) { - return false; - } - - $type = $parameters[0]->getType() instanceof \ReflectionNamedType ? $parameters[0]->getType()->getName() : null; - if ($type == 'Box_Event' || $type == "\Box_Event") { - return true; - } - - return false; - } - - /** - * Connect event for module. - * - * @throws \FOSSBilling\Exception - */ - private function connect($data): bool - { - $required = [ - 'event' => 'Hook event not passed', - 'mod' => 'Param mod not passed', - ]; - $this->di['validator']->checkRequiredParamsForArray($required, $data); - - $event = $data['event']; - $mod = $data['mod']; - - $q = "SELECT id - FROM extension_meta - WHERE extension = 'mod_hook' - AND rel_type = 'mod' - AND rel_id = :mod - AND meta_key = 'listener' - AND meta_value = :event - "; - if ($this->di['em']->getConnection()->fetchOne($q, ['mod' => $mod, 'event' => $event])) { - // already connected - return true; - } - - $meta = new ExtensionMeta(); - $meta->setExtension('mod_hook'); - $meta->setRelType('mod'); - $meta->setRelId($mod); - $meta->setMetaKey('listener'); - $meta->setMetaValue($event); - $this->di['em']->persist($meta); - $this->di['em']->flush(); - - return true; - } - - /** - * Disconnect unavailable listeners. - */ - private function _disconnectUnavailable(): void - { - $rm_sql = 'DELETE FROM extension_meta WHERE id = :id'; - - $sql = "SELECT id, rel_id, meta_value - FROM extension_meta - WHERE extension = 'mod_hook' - AND rel_type = 'mod' - AND meta_key = 'listener' - "; - $list = $this->di['em']->getConnection()->fetchAllAssociative($sql); - $extensionService = $this->di['mod_service']('extension'); - foreach ($list as $listener) { - try { - $mod_name = $listener['rel_id']; - $event = $listener['meta_value']; - - // disconnect modules without service class - $mod = $this->di['mod']($mod_name); - if (!$mod->hasService()) { - $this->di['em']->getConnection()->executeStatement($rm_sql, ['id' => $listener['id']]); - - continue; - } - - // Remove listeners that don't exist or aren't actually hooks - $s = $mod->getService(); - $reflector = new \ReflectionClass($s); - if (!$reflector->hasMethod($event) || !$this->canBeConnected($reflector->getMethod($event))) { - $this->di['em']->getConnection()->executeStatement($rm_sql, ['id' => $listener['id']]); - - continue; - } - - // If the listener is for a module that's not installed and is **not** a core module, remove the listener - $installed = $this->getExtensionRepository()->existsActiveByTypeAndName(Extension::TYPE_MOD, $mod_name); - if (!$installed && !$extensionService->isCoreModule($mod_name)) { - $this->di['em']->getConnection()->executeStatement($rm_sql, ['id' => $listener['id']]); - - continue; - } - } catch (\Exception $e) { - $this->di['logger']->error($e->getMessage()); - } - } - } - - private function getExtensionRepository(): ExtensionRepository - { - return $this->di['em']->getRepository(Extension::class); - } -} diff --git a/src/modules/Hook/manifest.json b/src/modules/Hook/manifest.json deleted file mode 100644 index f60e7d01d..000000000 --- a/src/modules/Hook/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "id": "hook", - "type": "mod", - "name": "Event hooks subsystem", - "description": "FOSSBilling core module", - "version": "1.0.0" -} diff --git a/src/modules/Hook/tests/Unit/Api/AdminTest.php b/src/modules/Hook/tests/Unit/Api/AdminTest.php deleted file mode 100644 index f32b7115e..000000000 --- a/src/modules/Hook/tests/Unit/Api/AdminTest.php +++ /dev/null @@ -1,90 +0,0 @@ -setDi($di); - $getDi = $api->getDi(); - expect($getDi)->toEqual($di); -}); - -test('getList returns array', function (): void { - $api = apiEndpoint(new Box\Mod\Hook\Api\Admin()); - $serviceMock = Mockery::mock(Box\Mod\Hook\Service::class); - /** @var Mockery\Expectation $expectation1 */ - $expectation1 = $serviceMock->shouldReceive('getSearchQuery'); - $expectation1->atLeast()->once(); - $expectation1->andReturn(['SqlString', []]); - - $paginatorMock = Mockery::mock(FOSSBilling\Pagination::class)->makePartial(); - $paginatorMock->shouldAllowMockingProtectedMethods(); - /** @var Mockery\Expectation $expectation2 */ - $expectation2 = $paginatorMock->shouldReceive('getPaginatedResultSet'); - $expectation2->atLeast()->once(); - $expectation2->andReturn([]); - - $di = container(); - $di['pager'] = $paginatorMock; - - $api->setDi($di); - $api->setService($serviceMock); - $result = $api->get_list([]); - expect($result)->toBeArray(); -}); - -test('call fires event', function (): void { - $api = apiEndpoint(new Box\Mod\Hook\Api\Admin()); - $data['event'] = 'testEvent'; - - $eventManager = Mockery::mock('\Box_EventManager'); - /** @var Mockery\Expectation $expectation */ - $expectation = $eventManager->shouldReceive('fire'); - $expectation->atLeast()->once(); - $expectation->andReturn(1); - - $di = container(); - $di['logger'] = new Tests\Helpers\TestLogger(); - $di['events_manager'] = $eventManager; - - $api->setDi($di); - $result = $api->call($data); - expect($result)->not->toBeEmpty(); -}); - -test('call returns false when event param is missing', function (): void { - $api = apiEndpoint(new Box\Mod\Hook\Api\Admin()); - $data['event'] = null; - - $result = $api->call($data); - expect($result)->toBeBool(); - expect($result)->toBeFalse(); -}); - -test('batchConnect returns result', function (): void { - $api = apiEndpoint(new Box\Mod\Hook\Api\Admin()); - $serviceMock = Mockery::mock(Box\Mod\Hook\Service::class); - /** @var Mockery\Expectation $expectation */ - $expectation = $serviceMock->shouldReceive('batchConnect'); - $expectation->atLeast()->once(); - $expectation->andReturn(true); - - $di = container(); - - $api->setDi($di); - - $api->setService($serviceMock); - $result = $api->batch_connect([]); - expect($result)->not->toBeEmpty(); -}); diff --git a/src/modules/Hook/tests/Unit/ServiceTest.php b/src/modules/Hook/tests/Unit/ServiceTest.php deleted file mode 100644 index 3c1edb0ba..000000000 --- a/src/modules/Hook/tests/Unit/ServiceTest.php +++ /dev/null @@ -1,265 +0,0 @@ -setDi($di); - $getDi = $service->getDi(); - expect($getDi)->toBe($di); -}); - -test('gets search query', function (): void { - $service = new Box\Mod\Hook\Service(); - [$sql, $params] = $service->getSearchQuery([]); - - expect($sql)->toBeString() - ->and($params)->toBeArray() - ->and(str_contains((string) $sql, 'SELECT id, rel_type, rel_id, meta_value as event, created_at, updated_at'))->toBeTrue() - ->and($params)->toBe([]); -}); - -test('gets search query applies allowlisted sort', function (): void { - $service = new Box\Mod\Hook\Service(); - - [$sql] = $service->getSearchQuery([]); - expect($sql)->toContain('ORDER BY id ASC'); - - [$sortedSql] = $service->getSearchQuery(['sort' => 'event', 'direction' => 'desc']); - expect($sortedSql)->toContain('ORDER BY meta_value DESC, id DESC'); - - [$pkSql] = $service->getSearchQuery(['sort' => 'id', 'direction' => 'desc']); - expect($pkSql)->toContain('ORDER BY id DESC'); - expect($pkSql)->not->toContain('id DESC, id DESC'); - - [$invalidSql] = $service->getSearchQuery(['sort' => 'rel_id']); - expect($invalidSql)->toContain('ORDER BY id ASC'); -}); - -test('converts to api array', function (): void { - $service = new Box\Mod\Hook\Service(); - $arrMock = ['testing' => 'okay']; - $result = $service->toApiArray($arrMock); - expect($result)->toBe($arrMock); -}); - -test('handles on after admin activate extension', function (): void { - $service = new Box\Mod\Hook\Service(); - $eventParams = [ - 'id' => 1, - ]; - - $eventMock = Mockery::mock('\Box_Event'); - /** @var Mockery\Expectation $expectation1 */ - $expectation1 = $eventMock->shouldReceive('getParameters'); - $expectation1->atLeast()->once(); - $expectation1->andReturn($eventParams); - /** @var Mockery\Expectation $expectation2 */ - $expectation2 = $eventMock->shouldReceive('setReturnValue'); - $expectation2->atLeast()->once(); - - $extension = createEntity(Box\Mod\Extension\Entity\Extension::class, ['id' => 1, 'type' => 'mod', 'name' => 'activity']); - - $extensionRepository = Mockery::mock(Box\Mod\Extension\Repository\ExtensionRepository::class); - /** @var Mockery\Expectation $expectation3 */ - $expectation3 = $extensionRepository->shouldReceive('find'); - $expectation3->atLeast()->once(); - $expectation3->andReturn($extension); - - $hookService = Mockery::mock(Box\Mod\Hook\Service::class); - /** @var Mockery\Expectation $expectation4 */ - $expectation4 = $hookService->shouldReceive('batchConnect'); - $expectation4->atLeast()->once(); - $expectation4->with('activity'); - - $di = container(); - $di['em']->shouldReceive('getRepository')->with(Box\Mod\Extension\Entity\Extension::class)->andReturn($extensionRepository); - $di['mod_service'] = $di->protect(fn ($name): Mockery\MockInterface => $hookService); - - /** @var Mockery\Expectation $expectation5 */ - $expectation5 = $eventMock->shouldReceive('getDi'); - $expectation5->atLeast()->once(); - $expectation5->andReturn($di); - - $service->setDi($di); - /* @var \Box_Event $eventMock */ - $service->onAfterAdminActivateExtension($eventMock); - $result = true; - expect($result)->toBeTrue(); -}); - -test('handles on after admin activate extension with missing id', function (): void { - $service = new Box\Mod\Hook\Service(); - $eventParams = []; - - $eventMock = Mockery::mock('\Box_Event'); - /** @var Mockery\Expectation $expectation1 */ - $expectation1 = $eventMock->shouldReceive('getParameters'); - $expectation1->atLeast()->once(); - $expectation1->andReturn($eventParams); - /** @var Mockery\Expectation $expectation2 */ - $expectation2 = $eventMock->shouldReceive('setReturnValue'); - $expectation2->atLeast()->once(); - - /* @var \Box_Event $eventMock */ - $service->onAfterAdminActivateExtension($eventMock); - $result = false; - expect($result)->toBeFalse(); -}); - -test('handles on after admin deactivate extension', function (): void { - $service = new Box\Mod\Hook\Service(); - $eventParams = [ - 'type' => 'mod', - 'id' => 1, - ]; - - $eventMock = Mockery::mock('\Box_Event'); - /** @var Mockery\Expectation $expectation1 */ - $expectation1 = $eventMock->shouldReceive('getParameters'); - $expectation1->atLeast()->once(); - $expectation1->andReturn($eventParams); - /** @var Mockery\Expectation $expectation2 */ - $expectation2 = $eventMock->shouldReceive('setReturnValue'); - $expectation2->atLeast()->once(); - - $connection = Mockery::mock(Doctrine\DBAL\Connection::class); - /** @var Mockery\Expectation $expectation3 */ - $expectation3 = $connection->shouldReceive('executeStatement'); - $expectation3->atLeast()->once(); - - $di = container(); - $di['em']->shouldReceive('getConnection')->andReturn($connection); - $eventsManager = Mockery::mock(Box_EventManager::class); - $eventsManager->shouldReceive('clearListenerCache')->once(); - $di['events_manager'] = $eventsManager; - - /** @var Mockery\Expectation $expectation4 */ - $expectation4 = $eventMock->shouldReceive('getDi'); - $expectation4->atLeast()->once(); - $expectation4->andReturn($di); - - $service->setDi($di); - /* @var \Box_Event $eventMock */ - $service->onAfterAdminDeactivateExtension($eventMock); - $result = true; - expect($result)->toBeTrue(); -}); - -test('batch connects', function (): void { - $service = new Box\Mod\Hook\Service(); - $mod = 'activity'; - - $data['mods'] = [$mod]; - - $connection = Mockery::mock(Doctrine\DBAL\Connection::class); - $connection->shouldReceive('getDatabasePlatform') - ->andReturn(Mockery::mock(Doctrine\DBAL\Platforms\MySQLPlatform::class)); - $connection->shouldReceive('fetchOne') - ->with(Mockery::on(fn ($sql): bool => str_contains((string) $sql, 'GET_LOCK')), Mockery::any()) - ->andReturn(1); - /** @var Mockery\Expectation $expectation1 */ - $expectation1 = $connection->shouldReceive('fetchOne') - ->with(Mockery::on(fn ($sql): bool => !str_contains((string) $sql, 'GET_LOCK')), Mockery::any()); - $expectation1->atLeast()->once(); - $expectation1->andReturn(false); - $connection->shouldReceive('executeStatement') - ->byDefault(); - $connection->shouldReceive('executeStatement') - ->with(Mockery::on(fn ($sql): bool => str_contains((string) $sql, 'RELEASE_LOCK')), Mockery::any()) - ->once(); - $connection->shouldReceive('transactional') - ->atLeast()->once() - ->andReturnUsing(fn (Closure $callback) => $callback($connection)); - - $returnArr = [ - [ - 'id' => 2, - 'rel_id' => 1, - 'meta_value' => 'testValue', - ], - ]; - /** @var Mockery\Expectation $expectation2 */ - $expectation2 = $connection->shouldReceive('fetchAllAssociative'); - $expectation2->atLeast()->once(); - $expectation2->andReturn($returnArr); - - $activityServiceMock = Mockery::mock(Box\Mod\Activity\Service::class); - - $boxModMock = Mockery::mock(FOSSBilling\Module::class); - /** @var Mockery\Expectation $expectation3 */ - $expectation3 = $boxModMock->shouldReceive('hasService'); - $expectation3->atLeast()->once(); - $expectation3->andReturn(true); - /** @var Mockery\Expectation $expectation4 */ - $expectation4 = $boxModMock->shouldReceive('getService'); - $expectation4->andReturn($activityServiceMock); - /** @var Mockery\Expectation $expectation5 */ - $expectation5 = $boxModMock->shouldReceive('getName'); - $expectation5->andReturn('activity'); - - $extensionServiceMock = Mockery::mock(Box\Mod\Extension\Service::class); - - $extensionRepository = Mockery::mock(Box\Mod\Extension\Repository\ExtensionRepository::class); - $extensionRepository->shouldReceive('existsActiveByTypeAndName') - ->byDefault() - ->andReturn(true); - - $di = container(); - $di['em']->shouldReceive('getConnection')->andReturn($connection); - $di['em']->shouldReceive('getRepository')->with(Box\Mod\Extension\Entity\Extension::class)->andReturn($extensionRepository); - $di['em']->shouldReceive('persist')->atLeast()->once(); - $di['em']->shouldReceive('flush')->atLeast()->once(); - $di['mod'] = $di->protect(fn () => $boxModMock); - $di['mod_service'] = $di->protect(function ($name) use ($extensionServiceMock) { - if ($name == 'extension') { - return $extensionServiceMock; - } - }); - $validatorMock = Mockery::mock(FOSSBilling\Validate::class); - /** @var Mockery\Expectation $validatorExpectation */ - $validatorExpectation = $validatorMock->shouldReceive('checkRequiredParamsForArray'); - $validatorExpectation->atLeast()->once(); - $di['validator'] = $validatorMock; - $eventsManager = Mockery::mock(Box_EventManager::class); - $eventsManager->shouldReceive('clearListenerCache')->once(); - $di['events_manager'] = $eventsManager; - $service->setDi($di); - $result = $service->batchConnect($mod); - expect($result)->toBeTrue(); -}); - -test('batch connect reports failure when another process holds the lock', function (): void { - $service = new Box\Mod\Hook\Service(); - - $connection = Mockery::mock(Doctrine\DBAL\Connection::class); - $connection->shouldReceive('getDatabasePlatform') - ->andReturn(Mockery::mock(Doctrine\DBAL\Platforms\MySQLPlatform::class)); - $connection->shouldReceive('fetchOne') - ->with(Mockery::on(fn ($sql): bool => str_contains((string) $sql, 'GET_LOCK')), Mockery::any()) - ->andReturn(0); - $connection->shouldNotReceive('transactional'); - $connection->shouldNotReceive('fetchAllAssociative'); - $connection->shouldNotReceive('executeStatement') - ->with(Mockery::on(fn ($sql): bool => str_contains((string) $sql, 'RELEASE_LOCK')), Mockery::any()); - - $di = container(); - $di['em']->shouldReceive('getConnection')->andReturn($connection); - $service->setDi($di); - - $result = $service->batchConnect('activity'); - - expect($result)->toBeFalse(); -}); diff --git a/src/modules/Invoice/Api/Admin.php b/src/modules/Invoice/Api/Admin.php index 2209808cd..2d9483505 100644 --- a/src/modules/Invoice/Api/Admin.php +++ b/src/modules/Invoice/Api/Admin.php @@ -20,6 +20,7 @@ use Box\Mod\Invoice\Entity\PayGateway; use Box\Mod\Invoice\Entity\Subscription; use Box\Mod\Invoice\Entity\Tax; use Box\Mod\Invoice\Entity\Transaction; +use Box\Mod\Invoice\Event\BeforeAdminTransactionProcessEvent; use Box\Mod\Order\Entity\Order; use FOSSBilling\InformationException; use FOSSBilling\PaginationOptions; @@ -467,10 +468,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi /** * Calls due events on unpaid and approved invoices. - * Attach custom event hooks events:. - * - * onEventBeforeInvoiceIsDue - event receives params: id and days_left - * onEventAfterInvoiceIsDue - event receives params: id and days_passed + * Extensions can listen to BeforeInvoiceIsDueEvent and AfterInvoiceIsDueEvent. * * @optional bool $once_per_day - default true. Pass false if you want to execute this action more than once per day * @@ -485,7 +483,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi /** * Send payment reminder notification for client. - * Calls event hook, so you can attach your custom notification code. + * Dispatches typed reminder events for custom notification code. * * @return bool */ @@ -538,7 +536,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi throw new \FOSSBilling\Exception('Transaction not found'); } - $this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminTransactionProcess', 'params' => ['id' => $model->getId()]]); + $this->getDi()['event_dispatcher']->dispatch(new BeforeAdminTransactionProcessEvent((int) $model->getId())); $transactionService = $this->getDi()['mod_service']('Invoice', 'Transaction'); diff --git a/src/modules/Invoice/Event/AfterAdminGenerateRenewalInvoiceEvent.php b/src/modules/Invoice/Event/AfterAdminGenerateRenewalInvoiceEvent.php new file mode 100644 index 000000000..ccdb8f45e --- /dev/null +++ b/src/modules/Invoice/Event/AfterAdminGenerateRenewalInvoiceEvent.php @@ -0,0 +1,22 @@ + $reminderIntervals */ + public function __construct( + public readonly int $invoiceId, + public readonly int $daysPassed, + public readonly array $reminderIntervals, + ) { + } +} diff --git a/src/modules/Invoice/Event/BeforeAdminGenerateRenewalInvoiceEvent.php b/src/modules/Invoice/Event/BeforeAdminGenerateRenewalInvoiceEvent.php new file mode 100644 index 000000000..0469e027c --- /dev/null +++ b/src/modules/Invoice/Event/BeforeAdminGenerateRenewalInvoiceEvent.php @@ -0,0 +1,20 @@ + $changedFields */ + public function __construct( + public readonly int $invoiceId, + public readonly array $changedFields, + ) { + } +} diff --git a/src/modules/Invoice/Event/BeforeAdminTransactionCreateEvent.php b/src/modules/Invoice/Event/BeforeAdminTransactionCreateEvent.php new file mode 100644 index 000000000..856ff583b --- /dev/null +++ b/src/modules/Invoice/Event/BeforeAdminTransactionCreateEvent.php @@ -0,0 +1,22 @@ + $input */ + public function __construct( + public readonly array $input, + ) { + } +} diff --git a/src/modules/Invoice/Event/BeforeAdminTransactionProcessEvent.php b/src/modules/Invoice/Event/BeforeAdminTransactionProcessEvent.php new file mode 100644 index 000000000..64cf21f12 --- /dev/null +++ b/src/modules/Invoice/Event/BeforeAdminTransactionProcessEvent.php @@ -0,0 +1,21 @@ + $reminderIntervals */ + public function __construct( + public readonly int $invoiceId, + public readonly int $daysLeft, + public readonly array $reminderIntervals, + ) { + } +} diff --git a/src/modules/Invoice/Service.php b/src/modules/Invoice/Service.php index 8ba5f0dfb..09e755cdd 100644 --- a/src/modules/Invoice/Service.php +++ b/src/modules/Invoice/Service.php @@ -13,11 +13,34 @@ namespace Box\Mod\Invoice; use Box\Mod\Client\Entity\Client; use Box\Mod\Client\Entity\ClientBalance; +use Box\Mod\Cron\Event\AfterAdminCronRunEvent; use Box\Mod\Currency\Entity\Currency; use Box\Mod\Invoice\Entity\Invoice; use Box\Mod\Invoice\Entity\InvoiceItem; use Box\Mod\Invoice\Entity\PayGateway; use Box\Mod\Invoice\Entity\Transaction; +use Box\Mod\Invoice\Event\AfterAdminGenerateRenewalInvoiceEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceApproveEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceAttachOrderEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceDebitEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceDeleteEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoicePaymentReceivedEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceRefundEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceReissueEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceReminderRecordedEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceUpdateEvent; +use Box\Mod\Invoice\Event\AfterInvoiceIsDueEvent; +use Box\Mod\Invoice\Event\BeforeAdminGenerateRenewalInvoiceEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceApproveEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceAttachOrderEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceDebitEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceDeleteEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceRefundEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceReissueEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceSendReminderEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceSendRemindersEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceUpdateEvent; +use Box\Mod\Invoice\Event\BeforeInvoiceIsDueEvent; use Box\Mod\Invoice\Repository\InvoiceItemRepository; use Box\Mod\Invoice\Repository\InvoiceRepository; use Box\Mod\Order\Entity\Order; @@ -35,6 +58,7 @@ use FOSSBilling\InformationException; use FOSSBilling\InjectionAwareInterface; use FOSSBilling\Tools; use FOSSBilling\Validation\PriceValidator; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; use Symfony\Component\HttpFoundation\HeaderUtils; @@ -540,78 +564,47 @@ class Service implements InjectionAwareInterface return $applications; } - public static function onAfterAdminInvoicePaymentReceived(\Box_Event $event): bool + #[AsEventListener] + public function sendPaidInvoiceEmail(AfterAdminInvoicePaymentReceivedEvent $event): void { - $params = $event->getParameters(); - $di = $event->getDi(); - $service = $di['mod_service']('invoice'); - try { - $invoiceModel = $di['em']->getRepository(Invoice::class)->find($params['id'] ?? 0); + $invoiceModel = $this->di['em']->getRepository(Invoice::class)->find($event->invoiceId); if (!$invoiceModel instanceof Invoice) { - return true; + return; } - $invoice = $service->toApiArray($invoiceModel, true, null, true); + $invoice = $this->toApiArray($invoiceModel, true, null, true); if (($invoice['total'] ?? 0) > 0) { - $service->sendInvoiceEmail($invoiceModel, $invoice, 'mod_invoice_paid'); + $this->sendInvoiceEmail($invoiceModel, $invoice, 'mod_invoice_paid'); } } catch (\Exception $exc) { - $di['logger']->withChannel('email')->error('Failed to send email for invoice payment', ['exception' => $exc]); + $this->di['logger']->withChannel('email')->error('Failed to send email for invoice payment', ['exception' => $exc]); } - - return true; } - public static function onAfterInvoiceCreate(\Box_Event $event): bool + #[AsEventListener] + public function sendApprovedInvoiceEmail(AfterAdminInvoiceApproveEvent $event): void { - $params = $event->getParameters(); - $di = $event->getDi(); - $service = $di['mod_service']('invoice'); - try { - $invoiceModel = $di['em']->getRepository(Invoice::class)->find($params['id']); + $invoiceModel = $this->di['em']->getRepository(Invoice::class)->find($event->invoiceId); if (!$invoiceModel instanceof Invoice) { - return true; + return; } - $invoice = $service->toApiArray($invoiceModel, true, null, true); - $service->sendInvoiceEmail($invoiceModel, $invoice, 'mod_invoice_created'); - } catch (\Exception $exc) { - $di['logger']->withChannel('email')->error('Failed to send email for invoice creation', ['exception' => $exc]); - } - - return true; - } - - public static function onAfterAdminInvoiceApprove(\Box_Event $event): bool - { - $params = $event->getParameters(); - $di = $event->getDi(); - $service = $di['mod_service']('invoice'); - - try { - $invoiceModel = $di['em']->getRepository(Invoice::class)->find($params['id'] ?? 0); - - if (($params['total'] ?? 0) > 0 - && ($params['status'] ?? null) !== Invoice::STATUS_PAID - && isset($params['client']['id']) + $invoice = $this->toApiArray($invoiceModel, true, null, true); + if (($invoice['total'] ?? 0) > 0 + && ($invoice['status'] ?? null) !== Invoice::STATUS_PAID + && isset($invoice['client']['id']) ) { - if ($invoiceModel instanceof Invoice) { - $service->sendInvoiceEmail($invoiceModel, $params, 'mod_invoice_created', (int) $params['client']['id']); - } + $this->sendInvoiceEmail($invoiceModel, $invoice, 'mod_invoice_created', (int) $invoice['client']['id']); } // Sending the created-email extends the hash lifetime so the // recipient has a fresh window to act on the link. - if ($invoiceModel instanceof Invoice) { - $service->extendInvoiceHashLifetime($invoiceModel); - } + $this->extendInvoiceHashLifetime($invoiceModel); } catch (\Exception $exc) { - $di['logger']->withChannel('email')->error('Failed to send email for invoice approval', ['exception' => $exc]); + $this->di['logger']->withChannel('email')->error('Failed to send email for invoice approval', ['exception' => $exc]); } - - return true; } private function sendInvoiceEmail(Invoice $invoice, array $invoiceData, string $templateCode, ?int $clientId = null, array $extraVars = []): void @@ -651,25 +644,23 @@ class Service implements InjectionAwareInterface $this->extendInvoiceHashLifetime($creditNote); } - public static function onAfterAdminInvoiceReminderSent(\Box_Event $event): void + public function sendInvoiceReminderEmail(AfterAdminInvoiceReminderRecordedEvent $event): void { - $params = $event->getParameters(); - $di = $event->getDi(); - $service = $di['mod_service']('invoice'); + $di = $this->di ?? throw new \LogicException('The Invoice service dependency injection container has not been set.'); try { - $invoiceModel = $di['em']->getRepository(Invoice::class)->find($params['id'] ?? 0); + $invoiceModel = $di['em']->getRepository(Invoice::class)->find($event->invoiceId); if (!$invoiceModel instanceof Invoice) { return; } - $invoice = $service->toApiArray($invoiceModel, true, null, true); + $invoice = $this->toApiArray($invoiceModel, true, null, true); $email = []; $email['to_client'] = $invoiceModel->getClientId(); $email['code'] = 'mod_invoice_payment_reminder'; $email['invoice'] = $invoice; - $email = $service->withBillingRecipient($email, $invoice); - $attachment = $service->getInvoicePdfAttachment($invoiceModel); + $email = $this->withBillingRecipient($email, $invoice); + $attachment = $this->getInvoicePdfAttachment($invoiceModel); if ($attachment !== null) { $email['attachment'] = $attachment; } @@ -678,21 +669,20 @@ class Service implements InjectionAwareInterface // Sending a payment reminder also re-extends the hash lifetime // since the recipient is being re-engaged via the same link. - $service->extendInvoiceHashLifetime($invoiceModel); - } catch (\Exception $exc) { + $this->extendInvoiceHashLifetime($invoiceModel); + } catch (\Throwable $exc) { $di['logger']->withChannel('email')->error('Failed to send invoice reminder email', ['exception' => $exc]); } } - public static function onEventBeforeInvoiceIsDue(\Box_Event $event): void + #[AsEventListener] + public function onEventBeforeInvoiceIsDue(BeforeInvoiceIsDueEvent $event): void { - $params = $event->getParameters(); - $di = $event->getDi(); - $service = $di['mod_service']('invoice'); + $di = $this->di ?? throw new \LogicException('The Invoice service dependency injection container has not been set.'); $claimed = false; try { - if (!$service->isInvoiceReminderIntervalEnabled('invoice_reminder_before_due_days', (int) ($params['days_left'] ?? 0), '', $params['reminder_intervals'] ?? null)) { + if (!$this->isInvoiceReminderIntervalEnabled('invoice_reminder_before_due_days', $event->daysLeft, '', $event->reminderIntervals)) { return; } @@ -704,7 +694,7 @@ class Service implements InjectionAwareInterface $claimed = (bool) $di['em']->getConnection()->executeStatement( "UPDATE invoice SET reminded_at = :now, updated_at = :now WHERE id = :id AND status = 'unpaid' AND approved = true AND due_at > :now AND (reminded_at IS NULL OR reminded_at < :today_start)", [ - 'id' => $params['id'] ?? 0, + 'id' => $event->invoiceId, 'now' => $now->format('Y-m-d H:i:s'), 'today_start' => $now->modify('today')->format('Y-m-d H:i:s'), ] @@ -713,25 +703,24 @@ class Service implements InjectionAwareInterface return; } - $invoiceModel = $di['em']->getRepository(Invoice::class)->find($params['id'] ?? 0); + $invoiceModel = $di['em']->getRepository(Invoice::class)->find($event->invoiceId); if ($invoiceModel instanceof Invoice) { - $service->sendInvoiceReminder($invoiceModel); + $this->sendInvoiceReminder($invoiceModel); } } catch (\Exception $exc) { if ($claimed) { - // sendInvoiceReminder()'s downstream send handler (onAfterAdminInvoiceReminderSent) - // catches its own failures internally, so any exception reaching here means the - // email was never queued. Release the claim so a later cron run retries it instead - // of the reminder being silently lost for the day. - $di['em']->getConnection()->executeStatement('UPDATE invoice SET reminded_at = NULL WHERE id = :id', ['id' => $params['id'] ?? 0]); + // sendInvoiceReminder() handles errors after recording separately. An exception + // reaching here occurred before the reminder was recorded, so release the claim. + $di['em']->getConnection()->executeStatement('UPDATE invoice SET reminded_at = NULL WHERE id = :id', ['id' => $event->invoiceId]); } - $di['logger']->withChannel('email')->error('Failed to send invoice reminder email', ['id' => $params['id'] ?? null, 'exception' => $exc]); + $di['logger']->withChannel('email')->error('Failed to send invoice reminder email', ['id' => $event->invoiceId, 'exception' => $exc]); } } - public static function onAfterAdminCronRun(\Box_Event $event): void + #[AsEventListener] + public function removeExpiredUnpaidInvoices(AfterAdminCronRunEvent $event): void { - $di = $event->getDi(); + $di = $this->di ?? throw new \LogicException('The Invoice service dependency injection container has not been set.'); $systemService = $di['mod_service']('System'); $remove_after_days = $systemService->getParamValue('remove_after_days'); if (isset($remove_after_days) && $remove_after_days) { @@ -748,15 +737,14 @@ class Service implements InjectionAwareInterface } } - public static function onEventAfterInvoiceIsDue(\Box_Event $event): void + #[AsEventListener] + public function onEventAfterInvoiceIsDue(AfterInvoiceIsDueEvent $event): void { - $params = $event->getParameters(); - $di = $event->getDi(); - $service = $di['mod_service']('invoice'); + $di = $this->di ?? throw new \LogicException('The Invoice service dependency injection container has not been set.'); $claimed = false; try { - if (!$service->isInvoiceReminderIntervalEnabled('invoice_reminder_after_due_days', (int) ($params['days_passed'] ?? 0), '5', $params['reminder_intervals'] ?? null)) { + if (!$this->isInvoiceReminderIntervalEnabled('invoice_reminder_after_due_days', $event->daysPassed, '5', $event->reminderIntervals)) { return; } @@ -773,7 +761,7 @@ class Service implements InjectionAwareInterface $claimed = (bool) $di['em']->getConnection()->executeStatement( "UPDATE invoice SET reminded_at = :now, updated_at = :now WHERE id = :id AND status = 'unpaid' AND approved = true AND due_at < :tomorrow_start AND (reminded_at IS NULL OR reminded_at < :today_start)", [ - 'id' => $params['id'] ?? 0, + 'id' => $event->invoiceId, 'now' => $now->format('Y-m-d H:i:s'), 'today_start' => $todayStart->format('Y-m-d H:i:s'), 'tomorrow_start' => $todayStart->modify('+1 day')->format('Y-m-d H:i:s'), @@ -783,12 +771,12 @@ class Service implements InjectionAwareInterface return; } - $invoiceModel = $di['em']->getRepository(Invoice::class)->find($params['id']); + $invoiceModel = $di['em']->getRepository(Invoice::class)->find($event->invoiceId); if (!$invoiceModel instanceof Invoice) { return; } - $invoice = $service->toApiArray($invoiceModel, true, null, true); + $invoice = $this->toApiArray($invoiceModel, true, null, true); if (!isset($invoice['client']) || !is_array($invoice['client']) || !isset($invoice['client']['id'])) { throw new \FOSSBilling\Exception('Invoice client data is unavailable.'); } @@ -796,10 +784,10 @@ class Service implements InjectionAwareInterface $email = []; $email['to_client'] = $invoice['client']['id']; $email['code'] = 'mod_invoice_due_after'; - $email['days_passed'] = $params['days_passed']; + $email['days_passed'] = $event->daysPassed; $email['invoice'] = $invoice; - $email = $service->withBillingRecipient($email, $invoice); - $attachment = $service->getInvoicePdfAttachment($invoiceModel); + $email = $this->withBillingRecipient($email, $invoice); + $attachment = $this->getInvoicePdfAttachment($invoiceModel); if ($attachment !== null) { $email['attachment'] = $attachment; } @@ -811,9 +799,9 @@ class Service implements InjectionAwareInterface // Nothing past sendTemplate() can throw, so reaching here with a claim already // made means the email was never confirmed queued. Release the claim so a later // cron run retries this invoice instead of losing the reminder. - $di['em']->getConnection()->executeStatement('UPDATE invoice SET reminded_at = NULL WHERE id = :id', ['id' => $params['id'] ?? 0]); + $di['em']->getConnection()->executeStatement('UPDATE invoice SET reminded_at = NULL WHERE id = :id', ['id' => $event->invoiceId]); } - $di['logger']->withChannel('email')->error('Failed to send overdue invoice email', ['id' => $params['id'] ?? null, 'exception' => $exc]); + $di['logger']->withChannel('email')->error('Failed to send overdue invoice email', ['id' => $event->invoiceId, 'exception' => $exc]); } } @@ -1202,7 +1190,7 @@ class Service implements InjectionAwareInterface public function approveInvoice(Invoice $invoice, array $data): bool { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminInvoiceApprove', 'params' => $this->toApiArray($invoice)]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminInvoiceApproveEvent((int) $invoice->getId())); $this->di['em']->wrapInTransaction(function () use ($invoice): void { $this->lockAndRefreshInvoice($invoice); @@ -1215,7 +1203,7 @@ class Service implements InjectionAwareInterface $this->tryPayWithCredits($invoice); } - $this->di['events_manager']->fire(['event' => 'onAfterAdminInvoiceApprove', 'params' => $this->toApiArray($invoice, true, null, true)]); + $this->di['event_dispatcher']->dispatch(new AfterAdminInvoiceApproveEvent((int) $invoice->getId())); $this->di['logger']->info("Approved invoice {$invoice->getId()}."); @@ -1312,7 +1300,7 @@ class Service implements InjectionAwareInterface private function firePaymentReceivedEvent(Invoice $invoice): void { - $this->di['events_manager']->fire(['event' => 'onAfterAdminInvoicePaymentReceived', 'params' => ['id' => $invoice->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminInvoicePaymentReceivedEvent((int) $invoice->getId())); } /** @@ -1377,7 +1365,7 @@ class Service implements InjectionAwareInterface public function refundInvoice(Invoice $invoice, $note = null, ?array $items = null): ?int { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminInvoiceRefund', 'params' => $this->toApiArray($invoice)]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminInvoiceRefundEvent((int) $invoice->getId())); $systemService = $this->di['mod_service']('system'); $logic = $systemService->getParamValue('invoice_refund_logic', 'manual'); @@ -1518,7 +1506,7 @@ class Service implements InjectionAwareInterface break; } - $this->di['events_manager']->fire(['event' => 'onAfterAdminInvoiceRefund', 'params' => ['id' => $invoice->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminInvoiceRefundEvent((int) $invoice->getId())); $this->di['logger']->info("Refunded invoice #{$invoice->getId()}."); @@ -1680,7 +1668,7 @@ class Service implements InjectionAwareInterface */ public function debitInvoice(Invoice $invoice, array $items, $note = null): int { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminInvoiceDebit', 'params' => $this->toApiArray($invoice)]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminInvoiceDebitEvent((int) $invoice->getId())); if (!$this->isDebitable($invoice)) { throw new InformationException('Only approved unpaid or paid invoices can be debited'); @@ -1791,7 +1779,7 @@ class Service implements InjectionAwareInterface ]); } - $this->di['events_manager']->fire(['event' => 'onAfterAdminInvoiceDebit', 'params' => ['id' => $invoice->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminInvoiceDebitEvent((int) $invoice->getId(), $result)); $this->di['logger']->info("Debited invoice #{$invoice->getId()}."); @@ -1875,7 +1863,7 @@ class Service implements InjectionAwareInterface */ public function attachOrderToInvoice(Invoice $invoice, array $data): int { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminInvoiceAttachOrder', 'params' => $this->toApiArray($invoice)]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminInvoiceAttachOrderEvent((int) $invoice->getId())); if (!$this->isInvoiceEditable($invoice)) { throw new InformationException('This invoice can no longer be edited. Approved invoices are locked once issued; correct them with a credit note or a replacement invoice.'); @@ -1907,7 +1895,7 @@ class Service implements InjectionAwareInterface } } - $this->di['events_manager']->fire(['event' => 'onAfterAdminInvoiceAttachOrder', 'params' => ['id' => $invoice->getId(), 'order_id' => $order->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminInvoiceAttachOrderEvent((int) $invoice->getId(), (int) $order->getId())); $this->di['logger']->info("Attached order {$order->getId()} to invoice {$invoice->getId()}."); @@ -1927,7 +1915,7 @@ class Service implements InjectionAwareInterface */ public function reissueInvoice(Invoice $original, array $data = []): int { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminInvoiceReissue', 'params' => $this->toApiArray($original)]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminInvoiceReissueEvent((int) $original->getId())); if (!$original->isApproved() || $original->getStatus() !== Invoice::STATUS_UNPAID) { throw new InformationException('Only approved unpaid invoices can be reissued'); @@ -2101,7 +2089,7 @@ class Service implements InjectionAwareInterface ]); } - $this->di['events_manager']->fire(['event' => 'onAfterAdminInvoiceReissue', 'params' => ['id' => $original->getId(), 'replacement_id' => $result]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminInvoiceReissueEvent((int) $original->getId(), $result)); $this->di['logger']->info("Reissued invoice #{$original->getId()} as #{$result}."); @@ -2264,7 +2252,9 @@ class Service implements InjectionAwareInterface $previousStatus = null; $wasApproved = false; - $this->di['events_manager']->fire(['event' => 'onBeforeAdminInvoiceUpdate', 'params' => $data]); + $changedFields = array_values(array_filter(array_keys($data), is_string(...))); + sort($changedFields); + $this->di['event_dispatcher']->dispatch(new BeforeAdminInvoiceUpdateEvent((int) $model->getId(), $changedFields)); $this->di['em']->wrapInTransaction(function () use ($model, $data, $invoiceItemService, &$previousStatus, &$wasApproved): void { $this->lockAndRefreshInvoice($model); @@ -2370,7 +2360,7 @@ class Service implements InjectionAwareInterface } }); - $this->di['events_manager']->fire(['event' => 'onAfterAdminInvoiceUpdate', 'params' => $this->toApiArray($model)]); + $this->di['event_dispatcher']->dispatch(new AfterAdminInvoiceUpdateEvent((int) $model->getId())); $this->di['logger']->info("Updated invoice {$model->getId()}."); @@ -2767,12 +2757,12 @@ class Service implements InjectionAwareInterface throw new InformationException('Only unapproved, unpaid invoices can be deleted. Revoke an approved invoice instead.'); } - $this->di['events_manager']->fire(['event' => 'onBeforeAdminInvoiceDelete', 'params' => ['id' => $model->getId()]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminInvoiceDeleteEvent((int) $model->getId())); $id = $model->getId(); $this->rmInvoice($model, true); - $this->di['events_manager']->fire(['event' => 'onAfterAdminInvoiceDelete', 'params' => ['id' => $id]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminInvoiceDeleteEvent((int) $id)); $this->di['logger']->info('Removed invoice #{id}', ['id' => $id]); @@ -2781,13 +2771,13 @@ class Service implements InjectionAwareInterface public function renewInvoice(Order $model, array $data): ?int { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminGenerateRenewalInvoice', 'params' => ['order_id' => $model->getId()]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminGenerateRenewalInvoiceEvent((int) $model->getId())); $due_days = isset($data['due_days']) ? (int) $data['due_days'] : null; $invoice = $this->generateForOrder($model, $due_days); $this->approveInvoice($invoice, ['id' => $invoice->getId(), 'use_credits' => true]); - $this->di['events_manager']->fire(['event' => 'onAfterAdminGenerateRenewalInvoice', 'params' => ['order_id' => $model->getId(), 'id' => $invoice->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminGenerateRenewalInvoiceEvent((int) $model->getId(), (int) $invoice->getId())); $this->di['logger']->info("Generated renewal invoice #{$invoice->getId()}."); @@ -2990,7 +2980,7 @@ class Service implements InjectionAwareInterface public function doBatchRemindersSend(): bool { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminInvoiceSendReminders']); + $this->di['event_dispatcher']->dispatch(new BeforeAdminInvoiceSendRemindersEvent()); $result = $this->doBatchInvokeDueEvent(['once_per_day' => true]); if (!$result) { // Pick up invoices that became reminder-eligible after today's due-event batch ran. @@ -3008,7 +2998,7 @@ class Service implements InjectionAwareInterface $once_per_day = isset($data['once_per_day']) ? (bool) $data['once_per_day'] : true; $key = 'invoice_overdue_invoked'; - // do not use api call to get system param to avoid invoking system module event hooks + // Read the setting directly to avoid dispatching system module events. $ss = $this->di['mod_service']('System'); $last_time = $ss->getParamValue($key); if ($once_per_day && $last_time && (time() - strtotime((string) $last_time)) < 86400) { @@ -3057,8 +3047,11 @@ class Service implements InjectionAwareInterface ['now' => $nowFormatted] ); foreach ($beforeDueList as $params) { - $params['reminder_intervals'] = $beforeDueReminderIntervals; - $this->di['events_manager']->fire(['event' => 'onEventBeforeInvoiceIsDue', 'params' => $params]); + $this->di['event_dispatcher']->dispatch(new BeforeInvoiceIsDueEvent( + (int) $params['id'], + (int) $params['days_left'], + $beforeDueReminderIntervals, + )); } // due_at < :tomorrow_start is a portable stand-in for MySQL's @@ -3070,8 +3063,11 @@ class Service implements InjectionAwareInterface ['now' => $nowFormatted, 'tomorrow_start' => $tomorrowStart] ); foreach ($afterDueList as $params) { - $params['reminder_intervals'] = $afterDueReminderIntervals; - $this->di['events_manager']->fire(['event' => 'onEventAfterInvoiceIsDue', 'params' => $params]); + $this->di['event_dispatcher']->dispatch(new AfterInvoiceIsDueEvent( + (int) $params['id'], + (int) $params['days_passed'], + $afterDueReminderIntervals, + )); } } @@ -3082,15 +3078,24 @@ class Service implements InjectionAwareInterface return true; } - $this->di['events_manager']->fire(['event' => 'onBeforeAdminInvoiceSendReminder', 'params' => ['id' => $invoice->getId()]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminInvoiceSendReminderEvent((int) $invoice->getId())); $invoice->setRemindedAt(new \DateTime()); $this->di['em']->persist($invoice); $this->di['em']->flush(); - $this->di['events_manager']->fire(['event' => 'onAfterAdminInvoiceReminderSent', 'params' => ['id' => $invoice->getId()]]); + $recordedEvent = new AfterAdminInvoiceReminderRecordedEvent((int) $invoice->getId()); + $this->sendInvoiceReminderEmail($recordedEvent); - $this->di['logger']->info('Invoice payment reminder sent'); + try { + $this->di['event_dispatcher']->dispatch($recordedEvent); + } catch (\Throwable $error) { + // The reminder was already recorded and the built-in email was attempted. A failing + // observer must not release the daily claim and cause a duplicate email on retry. + $this->di['logger']->withChannel('email')->error('Invoice reminder event listener failed', ['exception' => $error]); + } + + $this->di['logger']->info('Invoice payment reminder recorded'); return true; } diff --git a/src/modules/Invoice/ServiceSubscription.php b/src/modules/Invoice/ServiceSubscription.php index 06a540537..595fd90b3 100644 --- a/src/modules/Invoice/ServiceSubscription.php +++ b/src/modules/Invoice/ServiceSubscription.php @@ -15,6 +15,8 @@ use Box\Mod\Client\Entity\Client; use Box\Mod\Invoice\Entity\Invoice; use Box\Mod\Invoice\Entity\PayGateway; use Box\Mod\Invoice\Entity\Subscription; +use Box\Mod\Invoice\Event\AfterAdminSubscriptionCreateEvent; +use Box\Mod\Invoice\Event\AfterAdminSubscriptionDeleteEvent; use Box\Mod\Invoice\Repository\SubscriptionRepository; use Box\Mod\Order\Entity\Order; use FOSSBilling\InjectionAwareInterface; @@ -59,7 +61,7 @@ class ServiceSubscription implements InjectionAwareInterface $this->di['em']->flush(); $newId = (int) $model->getId(); - $this->di['events_manager']->fire(['event' => 'onAfterAdminSubscriptionCreate', 'params' => ['id' => $newId]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminSubscriptionCreateEvent($newId)); $this->di['logger']->info('Created subscription {subscription_id}', ['subscription_id' => $newId]); @@ -138,7 +140,7 @@ class ServiceSubscription implements InjectionAwareInterface $this->di['em']->remove($model); $this->di['em']->flush(); - $this->di['events_manager']->fire(['event' => 'onAfterAdminSubscriptionDelete', 'params' => ['id' => $id]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminSubscriptionDeleteEvent((int) $id)); $this->di['logger']->info('Removed subscription {id}', ['id' => $id]); diff --git a/src/modules/Invoice/ServiceTransaction.php b/src/modules/Invoice/ServiceTransaction.php index aca22d1fe..db0163715 100644 --- a/src/modules/Invoice/ServiceTransaction.php +++ b/src/modules/Invoice/ServiceTransaction.php @@ -17,6 +17,11 @@ use Box\Mod\Invoice\Entity\Invoice; use Box\Mod\Invoice\Entity\PayGateway; use Box\Mod\Invoice\Entity\Subscription; use Box\Mod\Invoice\Entity\Transaction; +use Box\Mod\Invoice\Event\AfterAdminTransactionCreateEvent; +use Box\Mod\Invoice\Event\AfterAdminTransactionProcessEvent; +use Box\Mod\Invoice\Event\AfterAdminTransactionUpdateEvent; +use Box\Mod\Invoice\Event\BeforeAdminTransactionCreateEvent; +use Box\Mod\Invoice\Event\BeforeAdminTransactionUpdateEvent; use Box\Mod\Invoice\Repository\TransactionRepository; use FOSSBilling\Environment; use FOSSBilling\InjectionAwareInterface; @@ -26,6 +31,11 @@ class ServiceTransaction implements InjectionAwareInterface { private const int PROCESSING_RECOVERY_TIMEOUT = 300; + /** @var list */ + private const array CREATE_EVENT_INPUT_FIELDS = [ + 'amount', 'currency', 'gateway_id', 'invoice_id', 'skip_validation', 'source', 'txn_id', 'txn_status', 'type', + ]; + protected ?\Pimple\Container $di = null; private ?bool $transactionIpnHashColumnExists = null; private ?TransactionRepository $transactionRepository = null; @@ -71,7 +81,8 @@ class ServiceTransaction implements InjectionAwareInterface public function update(Transaction $model, array $data): bool { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminTransactionUpdate', 'params' => ['id' => $model->getId()]]); + $transactionId = (int) $model->getId(); + $this->di['event_dispatcher']->dispatch(new BeforeAdminTransactionUpdateEvent($transactionId)); if (!empty($data['invoice_id'])) { $invoice = $this->di['em']->getRepository(Invoice::class)->find((int) $data['invoice_id']); @@ -101,7 +112,7 @@ class ServiceTransaction implements InjectionAwareInterface $model->setValidateIpn(isset($data['validate_ipn']) ? (bool) $data['validate_ipn'] : $model->isValidateIpn()); $model->setUpdatedAt(new \DateTime()); $this->di['em']->flush(); - $this->di['events_manager']->fire(['event' => 'onAfterAdminTransactionUpdate', 'params' => ['id' => $model->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminTransactionUpdateEvent($transactionId)); $this->di['logger']->info('Updated transaction #{model_id}', ['model_id' => $model->getId()]); @@ -162,7 +173,7 @@ class ServiceTransaction implements InjectionAwareInterface public function create(array $data): ?int { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminTransactionCreate', 'params' => $data]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminTransactionCreateEvent($this->getSafeCreateEventInput($data))); $skip_validation = Tools::normalizeBoolean($data['skip_validation'] ?? false); if (!empty($data['gateway_id'])) { @@ -254,7 +265,7 @@ class ServiceTransaction implements InjectionAwareInterface $this->di['logger']->info('Received transaction {transaction_id} from payment gateway {gateway_id}', ['transaction_id' => $newId, 'gateway_id' => $transaction->getGateway()?->getId()]); - $this->di['events_manager']->fire(['event' => 'onAfterAdminTransactionCreate', 'params' => ['id' => $newId]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminTransactionCreateEvent($newId)); return $newId; } @@ -284,6 +295,23 @@ class ServiceTransaction implements InjectionAwareInterface return $this->transactionIpnHashColumnExists; } + /** + * @param array $data + * + * @return array + */ + private function getSafeCreateEventInput(array $data): array + { + $input = []; + foreach (self::CREATE_EVENT_INPUT_FIELDS as $field) { + if (array_key_exists($field, $data) && ($data[$field] === null || is_scalar($data[$field]))) { + $input[$field] = $data[$field]; + } + } + + return $input; + } + public function delete(Transaction $model): bool { $id = $model->getId(); @@ -482,7 +510,7 @@ class ServiceTransaction implements InjectionAwareInterface // gateway adapter itself returns (some return void). $this->processTransactionWithErrorHandling((int) $model->getId()); - $this->di['events_manager']->fire(['event' => 'onAfterAdminTransactionProcess', 'params' => ['id' => $model->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminTransactionProcessEvent((int) $model->getId())); $this->di['logger']->info('Processed transaction #{model_id}', ['model_id' => $model->getId()]); return true; diff --git a/src/modules/Invoice/tests/Unit/Api/AdminTest.php b/src/modules/Invoice/tests/Unit/Api/AdminTest.php index a8df2e7ea..a20c3fdb1 100644 --- a/src/modules/Invoice/tests/Unit/Api/AdminTest.php +++ b/src/modules/Invoice/tests/Unit/Api/AdminTest.php @@ -17,6 +17,7 @@ use Box\Mod\Invoice\Entity\PayGateway; use Box\Mod\Invoice\Entity\Subscription; use Box\Mod\Invoice\Entity\Tax; use Box\Mod\Invoice\Entity\Transaction; +use Box\Mod\Invoice\Event\BeforeAdminTransactionProcessEvent; use Box\Mod\Invoice\Repository\InvoiceItemRepository; use Box\Mod\Invoice\Repository\InvoiceRepository; use Box\Mod\Invoice\Repository\PayGatewayRepository; @@ -31,6 +32,7 @@ use Box\Mod\Invoice\ServiceTransaction; use Box\Mod\Order\Entity\Order; use Box\Mod\Order\Repository\OrderRepository; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher; use function Tests\Helpers\container; use function Tests\Helpers\createEntity; @@ -699,10 +701,15 @@ test('processes a transaction', function (): void { 'id' => 1, ]; + $steps = []; $transactionService = Mockery::mock(ServiceTransaction::class); $transactionService->shouldReceive('preProcessTransaction') ->atLeast()->once() - ->andReturn(true); + ->andReturnUsing(function () use (&$steps): bool { + $steps[] = 'process'; + + return true; + }); $model = createEntity(Transaction::class, ['id' => 1]); $transactionRepo = Mockery::mock(TransactionRepository::class); @@ -710,20 +717,25 @@ test('processes a transaction', function (): void { $em = Mockery::mock(EntityManagerInterface::class); $em->shouldReceive('getRepository')->with(Transaction::class)->andReturn($transactionRepo); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire') - ->atLeast()->once(); + $dispatcher = new SymfonyEventDispatcher(); + $dispatcher->addListener(BeforeAdminTransactionProcessEvent::class, static function (BeforeAdminTransactionProcessEvent $event) use (&$steps): void { + $steps[] = $event; + }); $di = container(); $di['em'] = $em; - $di['events_manager'] = $eventsMock; + $di['event_dispatcher'] = $dispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod_service'] = $di->protect(moduleService(['invoice:transaction' => $transactionService])); $api->setDi($di); $result = $api->transaction_process($data); - expect($result)->toBeBool()->toBeTrue(); + expect($result)->toBeBool()->toBeTrue() + ->and($steps)->toHaveCount(2) + ->and($steps[0])->toBeInstanceOf(BeforeAdminTransactionProcessEvent::class) + ->and($steps[0]->transactionId)->toBe(1) + ->and($steps[1])->toBe('process'); }); test('updates a transaction', function (): void { diff --git a/src/modules/Invoice/tests/Unit/ServiceSubscriptionTest.php b/src/modules/Invoice/tests/Unit/ServiceSubscriptionTest.php index 61611ec7b..0816ec60b 100644 --- a/src/modules/Invoice/tests/Unit/ServiceSubscriptionTest.php +++ b/src/modules/Invoice/tests/Unit/ServiceSubscriptionTest.php @@ -62,18 +62,30 @@ test('gets dependency injection container', function (): void { ->and($service->getSubscriptionRepository())->toBe($repo); }); -test('creates a subscription', function (): void { +test('creates a subscription and dispatches its typed event', function (): void { + $calls = (object) ['entries' => []]; $em = Mockery::mock(EntityManagerInterface::class); - $em->shouldReceive('persist')->atLeast()->once(); - $em->shouldReceive('flush')->atLeast()->once(); + $em->shouldReceive('persist')->once()->with(Mockery::type(Subscription::class))->andReturnUsing(function (Subscription $subscription): void { + (new ReflectionProperty(Subscription::class, 'id'))->setValue($subscription, 42); + }); + $em->shouldReceive('flush')->once(); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire') - ->atLeast()->once(); + $eventDispatcher = new readonly class($calls) { + public function __construct(private object $calls) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->calls->entries[] = ['typed', $event]; + + return $event; + } + }; $service = subscriptionService(em: $em); $service->getDi()['logger'] = new Tests\Helpers\TestLogger(); - $service->getDi()['events_manager'] = $eventsMock; + $service->getDi()['event_dispatcher'] = $eventDispatcher; $data = [ 'client_id' => 1, @@ -84,7 +96,11 @@ test('creates a subscription', function (): void { $pg = createEntity(PayGateway::class, ['id' => 2]); $result = $service->create($client, $pg, $data); - expect($result)->toBeInt(); + expect($result)->toBe(42) + ->and($calls->entries)->toHaveCount(1) + ->and($calls->entries[0][0])->toBe('typed') + ->and($calls->entries[0][1])->toBeInstanceOf(Box\Mod\Invoice\Event\AfterAdminSubscriptionCreateEvent::class) + ->and($calls->entries[0][1]->subscriptionId)->toBe(42); }); test('updates a subscription', function (): void { @@ -373,23 +389,37 @@ test('converts to api array', function (): void { expect($result['gateway'])->toBeArray(); }); -test('deletes a subscription', function (): void { +test('deletes a subscription and dispatches its typed event', function (): void { + $calls = (object) ['entries' => []]; $em = Mockery::mock(EntityManagerInterface::class); - $em->shouldReceive('remove')->atLeast()->once(); - $em->shouldReceive('flush')->atLeast()->once(); + $em->shouldReceive('remove')->once(); + $em->shouldReceive('flush')->once(); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire') - ->atLeast()->once(); + $eventDispatcher = new readonly class($calls) { + public function __construct(private object $calls) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->calls->entries[] = ['typed', $event]; + + return $event; + } + }; $service = subscriptionService(em: $em); $service->getDi()['logger'] = new Tests\Helpers\TestLogger(); - $service->getDi()['events_manager'] = $eventsMock; + $service->getDi()['event_dispatcher'] = $eventDispatcher; $subscriptionModel = createEntity(Subscription::class, ['id' => 1]); $result = $service->delete($subscriptionModel); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($calls->entries)->toHaveCount(1) + ->and($calls->entries[0][0])->toBe('typed') + ->and($calls->entries[0][1])->toBeInstanceOf(Box\Mod\Invoice\Event\AfterAdminSubscriptionDeleteEvent::class) + ->and($calls->entries[0][1]->subscriptionId)->toBe(1); }); test('returns false when invoice is not subscribable', function (): void { diff --git a/src/modules/Invoice/tests/Unit/ServiceTest.php b/src/modules/Invoice/tests/Unit/ServiceTest.php index 8ae75d6b7..4b958af5e 100644 --- a/src/modules/Invoice/tests/Unit/ServiceTest.php +++ b/src/modules/Invoice/tests/Unit/ServiceTest.php @@ -12,6 +12,7 @@ declare(strict_types=1); use Box\Mod\Client\Entity\ClientBalance; use Box\Mod\Client\Service as ClientService; +use Box\Mod\Cron\Event\AfterAdminCronRunEvent; use Box\Mod\Currency\Entity\Currency as CurrencyEntity; use Box\Mod\Currency\Repository\CurrencyRepository; use Box\Mod\Currency\Service as CurrencyService; @@ -21,6 +22,28 @@ use Box\Mod\Invoice\Entity\InvoiceItem; use Box\Mod\Invoice\Entity\PayGateway; use Box\Mod\Invoice\Entity\Subscription; use Box\Mod\Invoice\Entity\Transaction; +use Box\Mod\Invoice\Event\AfterAdminGenerateRenewalInvoiceEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceApproveEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceAttachOrderEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceDebitEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceDeleteEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoicePaymentReceivedEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceRefundEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceReissueEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceReminderRecordedEvent; +use Box\Mod\Invoice\Event\AfterAdminInvoiceUpdateEvent; +use Box\Mod\Invoice\Event\AfterInvoiceIsDueEvent; +use Box\Mod\Invoice\Event\BeforeAdminGenerateRenewalInvoiceEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceApproveEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceAttachOrderEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceDebitEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceDeleteEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceRefundEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceReissueEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceSendReminderEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceSendRemindersEvent; +use Box\Mod\Invoice\Event\BeforeAdminInvoiceUpdateEvent; +use Box\Mod\Invoice\Event\BeforeInvoiceIsDueEvent; use Box\Mod\Invoice\Repository\InvoiceItemRepository; use Box\Mod\Invoice\Repository\InvoiceRepository; use Box\Mod\Invoice\Repository\PayGatewayRepository; @@ -38,6 +61,7 @@ use Box\Mod\Product\Entity\Product; use Box\Mod\Product\Service as ProductService; use Box\Mod\System\Service as SystemService; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\EventDispatcher\EventDispatcher; use function Tests\Helpers\container; use function Tests\Helpers\createEntity; @@ -368,55 +392,35 @@ test('to api array self-heals invoice with missing hash', function (): void { }); test('handles after admin invoice payment received event', function (): void { - $service = new Service(); - $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $service = Mockery::mock(Service::class)->makePartial(); $arr = [ 'total' => 1, 'client' => [ 'id' => 0, ], ]; - $serviceMock->shouldReceive('toApiArray') - ->atLeast()->once() - ->andReturn($arr); - $serviceMock->shouldReceive('getInvoicePdfAttachment') - ->atLeast()->once() - ->andReturn(null); - - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once(); + $service->shouldReceive('toApiArray')->once()->andReturn($arr); + $service->shouldReceive('withBillingRecipient')->once()->andReturnUsing(static fn (array $email): array => $email); + $service->shouldReceive('getInvoicePdfAttachment')->once()->andReturnNull(); $emailService = Mockery::mock(EmailService::class); $emailService->shouldReceive('sendTemplate') - ->atLeast()->once(); + ->once() + ->with(Mockery::on(static fn (array $email): bool => $email['code'] === 'mod_invoice_paid' && $email['invoice'] === $arr)); - $invoiceModel = createEntity(Invoice::class); + $invoiceModel = createEntity(Invoice::class, ['id' => 18]); $di = container(); $di['em']->getRepository(Invoice::class)->shouldReceive('find') - ->atLeast()->once() + ->once()->with(18) ->andReturn($invoiceModel); - $di['mod_service'] = $di->protect(function ($serviceName, $sub = '') use ($emailService, $serviceMock) { - if ($serviceName === 'invoice') { - return $serviceMock; - } - if ($serviceName === 'email') { - return $emailService; - } - }); + $di['mod_service'] = $di->protect(moduleService(['email' => $emailService])); $service->setDi($di); - $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - - $result = $service->onAfterAdminInvoicePaymentReceived($eventMock); - expect($result)->toBeBool()->toBeTrue(); + $service->sendPaidInvoiceEmail(new AfterAdminInvoicePaymentReceivedEvent(18)); }); -test('handles after admin invoice reminder sent event', function (): void { +test('handles after admin invoice reminder recorded event', function (): void { $service = new Service(); $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); $arr = [ @@ -432,9 +436,7 @@ test('handles after admin invoice reminder sent event', function (): void { ->atLeast()->once() ->andReturn(null); - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once(); + $eventMock = new AfterAdminInvoiceReminderRecordedEvent(1); $emailService = Mockery::mock(EmailService::class); $emailService->shouldReceive('sendTemplate') @@ -465,16 +467,11 @@ test('handles after admin invoice reminder sent event', function (): void { $service->setDi($di); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $service->onAfterAdminInvoiceReminderSent($eventMock); + $serviceMock->sendInvoiceReminderEmail($eventMock); }); -test('handles after admin cron run event', function (): void { - $eventMock = Mockery::mock('\Box_Event'); - +test('removes expired unpaid invoices on typed after cron event', function (): void { $remove_after_days = 64; $systemServiceMock = Mockery::mock(SystemService::class); $systemServiceMock->shouldReceive('getParamValue') @@ -502,11 +499,7 @@ test('handles after admin cron run event', function (): void { }); $invoiceServiceMock->setDi($di); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - - Service::onAfterAdminCronRun($eventMock); + $invoiceServiceMock->removeExpiredUnpaidInvoices(new AfterAdminCronRunEvent()); }); test('uses the client billing email for invoice notifications', function (): void { @@ -539,21 +532,12 @@ test('handles event after invoice is due', function (): void { ->atLeast()->once() ->andReturn(null); - $eventMock = Mockery::mock('\Box_Event'); - $params = ['days_passed' => 5, 'id' => 1]; - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn($params); + $eventMock = new AfterInvoiceIsDueEvent(1, 5, [5]); $emailService = Mockery::mock(EmailService::class); $emailService->shouldReceive('sendTemplate') ->atLeast()->once(); - $systemService = Mockery::mock(SystemService::class); - $systemService->shouldReceive('getParamValue') - ->with('invoice_reminder_after_due_days', '5') - ->andReturn('1, 5, 7'); - $invoiceModel = createEntity(Invoice::class); $connection = Mockery::mock(Doctrine\DBAL\Connection::class); @@ -567,22 +551,16 @@ test('handles event after invoice is due', function (): void { $di['em']->getRepository(Invoice::class)->shouldReceive('find') ->atLeast()->once() ->andReturn($invoiceModel); - $di['mod_service'] = $di->protect(function ($serviceName) use ($emailService, $serviceMock, $systemService) { + $di['mod_service'] = $di->protect(function ($serviceName) use ($emailService, $serviceMock) { if ($serviceName == 'invoice') { return $serviceMock; } if ($serviceName == 'email') { return $emailService; } - if ($serviceName == 'system') { - return $systemService; - } }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); $serviceMock->onEventAfterInvoiceIsDue($eventMock); }); @@ -593,20 +571,12 @@ test('skips overdue invoice reminder when the invoice was already claimed', func $serviceMock->shouldReceive('getInvoicePdfAttachment') ->never(); - $eventMock = Mockery::mock('\\Box_Event'); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['days_passed' => 5, 'id' => 1]); + $eventMock = new AfterInvoiceIsDueEvent(1, 5, [5]); $emailService = Mockery::mock(EmailService::class); $emailService->shouldReceive('sendTemplate') ->never(); - $systemService = Mockery::mock(SystemService::class); - $systemService->shouldReceive('getParamValue') - ->with('invoice_reminder_after_due_days', '5') - ->andReturn('1, 5, 7'); - $connection = Mockery::mock(Doctrine\DBAL\Connection::class); $connection->shouldReceive('executeStatement') ->once() @@ -614,24 +584,18 @@ test('skips overdue invoice reminder when the invoice was already claimed', func ->andReturn(0); $di = container(); - $di['mod_service'] = $di->protect(function ($serviceName) use ($emailService, $serviceMock, $systemService) { + $di['mod_service'] = $di->protect(function ($serviceName) use ($emailService, $serviceMock) { if ($serviceName == 'invoice') { return $serviceMock; } if ($serviceName == 'email') { return $emailService; } - if ($serviceName == 'system') { - return $systemService; - } }); $di['em']->shouldReceive('getConnection')->andReturn($connection); $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); $serviceMock->onEventAfterInvoiceIsDue($eventMock); }); @@ -651,22 +615,13 @@ test('releases the claim when sending the overdue invoice email fails', function ->atLeast()->once() ->andReturn(null); - $eventMock = Mockery::mock('\Box_Event'); - $params = ['days_passed' => 5, 'id' => 1]; - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn($params); + $eventMock = new AfterInvoiceIsDueEvent(1, 5, [5]); $emailService = Mockery::mock(EmailService::class); $emailService->shouldReceive('sendTemplate') ->atLeast()->once() ->andThrow(new Exception('SMTP timeout')); - $systemService = Mockery::mock(SystemService::class); - $systemService->shouldReceive('getParamValue') - ->with('invoice_reminder_after_due_days', '5') - ->andReturn('1, 5, 7'); - $invoiceModel = createEntity(Invoice::class); $connection = Mockery::mock(Doctrine\DBAL\Connection::class); @@ -685,23 +640,17 @@ test('releases the claim when sending the overdue invoice email fails', function $di['em']->getRepository(Invoice::class)->shouldReceive('find') ->atLeast()->once() ->andReturn($invoiceModel); - $di['mod_service'] = $di->protect(function ($serviceName) use ($emailService, $serviceMock, $systemService) { + $di['mod_service'] = $di->protect(function ($serviceName) use ($emailService, $serviceMock) { if ($serviceName == 'invoice') { return $serviceMock; } if ($serviceName == 'email') { return $emailService; } - if ($serviceName == 'system') { - return $systemService; - } }); $di['logger'] = $logger; $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); $serviceMock->onEventAfterInvoiceIsDue($eventMock); expect($logger->calls)->not->toBeEmpty(); @@ -715,20 +664,12 @@ test('releases the overdue reminder claim when invoice client data is unavailabl $serviceMock->shouldReceive('getInvoicePdfAttachment') ->never(); - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['days_passed' => 5, 'id' => 1]); + $eventMock = new AfterInvoiceIsDueEvent(1, 5, [5]); $emailService = Mockery::mock(EmailService::class); $emailService->shouldReceive('sendTemplate') ->never(); - $systemService = Mockery::mock(SystemService::class); - $systemService->shouldReceive('getParamValue') - ->with('invoice_reminder_after_due_days', '5') - ->andReturn('1, 5, 7'); - $invoiceModel = createEntity(Invoice::class); $connection = Mockery::mock(Doctrine\DBAL\Connection::class); @@ -747,23 +688,17 @@ test('releases the overdue reminder claim when invoice client data is unavailabl $di['em']->getRepository(Invoice::class)->shouldReceive('find') ->once() ->andReturn($invoiceModel); - $di['mod_service'] = $di->protect(function ($serviceName) use ($emailService, $serviceMock, $systemService) { + $di['mod_service'] = $di->protect(function ($serviceName) use ($emailService, $serviceMock) { if ($serviceName == 'invoice') { return $serviceMock; } if ($serviceName == 'email') { return $emailService; } - if ($serviceName == 'system') { - return $systemService; - } }); $di['logger'] = $logger; $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); $serviceMock->onEventAfterInvoiceIsDue($eventMock); expect($logger->calls)->not->toBeEmpty(); @@ -776,15 +711,7 @@ test('handles event before invoice is due', function (): void { ->once() ->andReturnTrue(); - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['days_left' => 7, 'id' => 1]); - - $systemService = Mockery::mock(SystemService::class); - $systemService->shouldReceive('getParamValue') - ->with('invoice_reminder_before_due_days', '') - ->andReturn('14, 7, 1'); + $eventMock = new BeforeInvoiceIsDueEvent(1, 7, [7]); $invoiceModel = createEntity(Invoice::class); @@ -799,22 +726,16 @@ test('handles event before invoice is due', function (): void { $di['em']->getRepository(Invoice::class)->shouldReceive('find') ->once() ->andReturn($invoiceModel); - $di['mod_service'] = $di->protect(function ($serviceName) use ($serviceMock, $systemService) { + $di['mod_service'] = $di->protect(function ($serviceName) use ($serviceMock) { if ($serviceName == 'invoice') { return $serviceMock; } - if ($serviceName == 'system') { - return $systemService; - } }); $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $service->onEventBeforeInvoiceIsDue($eventMock); + $serviceMock->onEventBeforeInvoiceIsDue($eventMock); }); test('releases the claim when sending the before-due invoice reminder fails', function (): void { @@ -824,15 +745,7 @@ test('releases the claim when sending the before-due invoice reminder fails', fu ->once() ->andThrow(new Exception('DB write failed')); - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['days_left' => 7, 'id' => 1]); - - $systemService = Mockery::mock(SystemService::class); - $systemService->shouldReceive('getParamValue') - ->with('invoice_reminder_before_due_days', '') - ->andReturn('14, 7, 1'); + $eventMock = new BeforeInvoiceIsDueEvent(1, 7, [7]); $invoiceModel = createEntity(Invoice::class); @@ -852,22 +765,16 @@ test('releases the claim when sending the before-due invoice reminder fails', fu $di['em']->getRepository(Invoice::class)->shouldReceive('find') ->once() ->andReturn($invoiceModel); - $di['mod_service'] = $di->protect(function ($serviceName) use ($serviceMock, $systemService) { + $di['mod_service'] = $di->protect(function ($serviceName) use ($serviceMock) { if ($serviceName == 'invoice') { return $serviceMock; } - if ($serviceName == 'system') { - return $systemService; - } }); $di['logger'] = $logger; $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $service->onEventBeforeInvoiceIsDue($eventMock); + $serviceMock->onEventBeforeInvoiceIsDue($eventMock); expect($logger->calls)->not->toBeEmpty(); }); @@ -878,15 +785,7 @@ test('skips before due invoice reminder when the invoice was already claimed', f $serviceMock->shouldReceive('sendInvoiceReminder') ->never(); - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['days_left' => 7, 'id' => 1]); - - $systemService = Mockery::mock(SystemService::class); - $systemService->shouldReceive('getParamValue') - ->with('invoice_reminder_before_due_days', '') - ->andReturn('14, 7, 1'); + $eventMock = new BeforeInvoiceIsDueEvent(1, 7, [7]); $connection = Mockery::mock(Doctrine\DBAL\Connection::class); $connection->shouldReceive('executeStatement') @@ -895,23 +794,17 @@ test('skips before due invoice reminder when the invoice was already claimed', f ->andReturn(0); $di = container(); - $di['mod_service'] = $di->protect(function ($serviceName) use ($serviceMock, $systemService) { + $di['mod_service'] = $di->protect(function ($serviceName) use ($serviceMock) { if ($serviceName == 'invoice') { return $serviceMock; } - if ($serviceName == 'system') { - return $systemService; - } }); $di['em']->shouldReceive('getConnection')->andReturn($connection); $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $service->onEventBeforeInvoiceIsDue($eventMock); + $serviceMock->onEventBeforeInvoiceIsDue($eventMock); }); test('skips before due invoice reminder when interval does not match', function (): void { @@ -920,34 +813,20 @@ test('skips before due invoice reminder when interval does not match', function $serviceMock->shouldReceive('sendInvoiceReminder') ->never(); - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['days_left' => 3, 'id' => 1]); - - $systemService = Mockery::mock(SystemService::class); - $systemService->shouldReceive('getParamValue') - ->with('invoice_reminder_before_due_days', '') - ->andReturn('14, 7, 1'); + $eventMock = new BeforeInvoiceIsDueEvent(1, 3, [1, 7, 14]); $di = container(); - $di['mod_service'] = $di->protect(function ($serviceName) use ($serviceMock, $systemService) { + $di['mod_service'] = $di->protect(function ($serviceName) use ($serviceMock) { if ($serviceName == 'invoice') { return $serviceMock; } - if ($serviceName == 'system') { - return $systemService; - } }); $di['em']->shouldReceive('getConnection')->never(); $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $service->onEventBeforeInvoiceIsDue($eventMock); + $serviceMock->onEventBeforeInvoiceIsDue($eventMock); }); test('skips before due invoice reminder when intervals are blank', function (): void { @@ -956,34 +835,20 @@ test('skips before due invoice reminder when intervals are blank', function (): $serviceMock->shouldReceive('sendInvoiceReminder') ->never(); - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['days_left' => 7, 'id' => 1]); - - $systemService = Mockery::mock(SystemService::class); - $systemService->shouldReceive('getParamValue') - ->with('invoice_reminder_before_due_days', '') - ->andReturn(''); + $eventMock = new BeforeInvoiceIsDueEvent(1, 7, []); $di = container(); - $di['mod_service'] = $di->protect(function ($serviceName) use ($serviceMock, $systemService) { + $di['mod_service'] = $di->protect(function ($serviceName) use ($serviceMock) { if ($serviceName == 'invoice') { return $serviceMock; } - if ($serviceName == 'system') { - return $systemService; - } }); $di['em']->shouldReceive('getConnection')->never(); $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $service->onEventBeforeInvoiceIsDue($eventMock); + $serviceMock->onEventBeforeInvoiceIsDue($eventMock); }); test('marks invoice as paid', function (): void { @@ -1018,9 +883,19 @@ test('marks invoice as paid', function (): void { ->atLeast()->once() ->andReturn($currencyRepositoryMock); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire') - ->atLeast()->once(); + $events = []; + $eventDispatcher = new class($events) { + public function __construct(private array &$events) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; [$em, $invoiceItemRepo] = invoiceItemEmAndRepo(); $invoiceItemRepo->shouldReceive('findByInvoiceId') @@ -1048,12 +923,15 @@ test('marks invoice as paid', function (): void { } }); $di['em'] = $em; - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); $result = $serviceMock->markAsPaid($invoiceModel, true, true); - expect($result)->toBeBool()->toBeTrue(); + expect($result)->toBeBool()->toBeTrue() + ->and($events)->toHaveCount(1) + ->and($events[0])->toBeInstanceOf(AfterAdminInvoicePaymentReceivedEvent::class) + ->and($events[0]->invoiceId)->toBe((int) $invoiceModel->getId()); }); test('markAsPaidByAdmin refuses canceled and replaced invoices before any write', function (): void { @@ -1153,8 +1031,16 @@ test('admin mark as paid with custom gateway records transaction and marks invoi $invoiceItemRepo->shouldReceive('findByInvoiceId')->once()->with(10)->andReturn([$invoiceItemModel = createEntity(InvoiceItem::class)]); $em->shouldReceive('flush')->once(); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->once()->with(['event' => 'onAfterAdminInvoicePaymentReceived', 'params' => ['id' => 10]]); + $eventDispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $invoiceItemServiceMock = Mockery::mock(ServiceInvoiceItem::class); $invoiceItemServiceMock->shouldReceive('executeTask')->once()->with($invoiceItemModel); @@ -1165,7 +1051,7 @@ test('admin mark as paid with custom gateway records transaction and marks invoi 'invoice:transaction' => $transactionServiceMock, 'invoice:invoiceitem' => $invoiceItemServiceMock, ])); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); @@ -1179,7 +1065,10 @@ test('admin mark as paid with custom gateway records transaction and marks invoi ->and($transactionModel->getAmount())->toBe('42.5') ->and($transactionModel->getCurrency())->toBe('USD') ->and($transactionModel->getStatus())->toBe(Transaction::STATUS_PROCESSED) - ->and($transactionModel->getNote())->toBe('Manual payment transaction No: manual-reference-1'); + ->and($transactionModel->getNote())->toBe('Manual payment transaction No: manual-reference-1') + ->and($eventDispatcher->events)->toHaveCount(1) + ->and($eventDispatcher->events[0])->toBeInstanceOf(AfterAdminInvoicePaymentReceivedEvent::class) + ->and($eventDispatcher->events[0]->invoiceId)->toBe(10); }); test('admin mark as paid with custom gateway rejects transaction linked to another invoice', function (): void { @@ -1440,32 +1329,78 @@ test('sets invoice defaults', function (): void { }); test('approves an invoice', function (): void { - $service = new Service(); $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $steps = []; $serviceMock->shouldReceive('tryPayWithCredits') - ->atLeast()->once(); - $serviceMock->shouldReceive('toApiArray') - ->atLeast()->once() - ->andReturn(['id' => 1]); + ->once() + ->andReturnUsing(function () use (&$steps): bool { + $steps[] = 'credits'; + + return true; + }); $data['use_credits'] = true; $invoiceModel = createEntity(Invoice::class); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire') - ->atLeast()->once(); + $eventDispatcher = new class($steps) { + public function __construct(private array &$steps) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->steps[] = $event; + + return $event; + } + }; $di = container(); $di['em']->shouldReceive('persist')->atLeast()->once(); $di['em']->shouldReceive('flush')->atLeast()->once(); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); $result = $serviceMock->approveInvoice($invoiceModel, $data); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($steps)->toHaveCount(3) + ->and($steps[0])->toBeInstanceOf(BeforeAdminInvoiceApproveEvent::class) + ->and($steps[0]->invoiceId)->toBe((int) $invoiceModel->getId()) + ->and($steps[1])->toBe('credits') + ->and($steps[2])->toBeInstanceOf(AfterAdminInvoiceApproveEvent::class) + ->and($steps[2]->invoiceId)->toBe((int) $invoiceModel->getId()); +}); + +test('typed invoice approval listener emails an unpaid invoice and extends its link', function (): void { + $invoice = createEntity(Invoice::class, ['id' => 14]); + $invoiceData = ['id' => 14, 'total' => 25.0, 'status' => Invoice::STATUS_UNPAID, 'client' => ['id' => 8]]; + + $invoiceRepository = Mockery::mock(InvoiceRepository::class); + $invoiceRepository->shouldReceive('find')->once()->with(14)->andReturn($invoice); + $em = Mockery::mock(EntityManagerInterface::class); + $em->shouldReceive('getRepository')->once()->with(Invoice::class)->andReturn($invoiceRepository); + + $emailService = Mockery::mock(EmailService::class); + $emailService->shouldReceive('sendTemplate')->once()->with(Mockery::on(static fn (array $email): bool => $email['to_client'] === 8 + && $email['code'] === 'mod_invoice_created' + && $email['invoice'] === $invoiceData)); + + $service = Mockery::mock(Service::class)->makePartial(); + $service->shouldReceive('toApiArray')->once()->with($invoice, true, null, true)->andReturn($invoiceData); + $service->shouldReceive('withBillingRecipient')->once()->andReturnUsing(static fn (array $email): array => $email); + $service->shouldReceive('getInvoicePdfAttachment')->once()->with($invoice)->andReturnNull(); + $service->shouldReceive('extendInvoiceHashLifetime')->once()->with($invoice); + + $di = container(); + $di['em'] = $em; + $di['mod_service'] = $di->protect(moduleService(['email' => $emailService])); + $di['logger'] = new Tests\Helpers\TestLogger(); + $service->setDi($di); + + $service->sendApprovedInvoiceEmail(new AfterAdminInvoiceApproveEvent(14)); }); test('gets total with tax', function (): void { @@ -1774,9 +1709,19 @@ test('refunds invoice with negative invoice logic', function (): void { $invoiceItemModel = createEntity(InvoiceItem::class, []); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire') - ->atLeast()->once(); + $events = []; + $eventDispatcher = new class($events) { + public function __construct(private array &$events) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $systemService = Mockery::mock(SystemService::class); $systemService->shouldReceive('getParamValue') @@ -1832,13 +1777,18 @@ test('refunds invoice with negative invoice logic', function (): void { 'system' => $systemService, 'email' => $emailService, ])); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); $result = $serviceMock->refundInvoice($invoiceModel, 'customNote'); expect($result)->toBeInt()->toBe($newId); expect($invoiceModel->getStatus())->toBe(Invoice::STATUS_REFUNDED); + expect($events)->toHaveCount(2) + ->and($events[0])->toBeInstanceOf(BeforeAdminInvoiceRefundEvent::class) + ->and($events[0]->invoiceId)->toBe($newId) + ->and($events[1])->toBeInstanceOf(AfterAdminInvoiceRefundEvent::class) + ->and($events[1]->invoiceId)->toBe($newId); $creditNote = null; foreach ($persisted as $entity) { @@ -1867,8 +1817,19 @@ test('refunds invoice with credit note logic and reserved numbering', function ( $invoiceItemModel = createEntity(InvoiceItem::class, []); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->atLeast()->once(); + $events = []; + $eventDispatcher = new class($events) { + public function __construct(private array &$events) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $systemService = Mockery::mock(SystemService::class); $systemService->shouldReceive('getParamValue') @@ -1924,11 +1885,16 @@ test('refunds invoice with credit note logic and reserved numbering', function ( 'system' => $systemService, 'email' => $emailService, ])); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); expect($serviceMock->refundInvoice($invoiceModel))->toBe($newId); + expect($events)->toHaveCount(2) + ->and($events[0])->toBeInstanceOf(BeforeAdminInvoiceRefundEvent::class) + ->and($events[0]->invoiceId)->toBe(9) + ->and($events[1])->toBeInstanceOf(AfterAdminInvoiceRefundEvent::class) + ->and($events[1]->invoiceId)->toBe(9); expect($invoiceModel->getStatus())->toBe(Invoice::STATUS_REFUNDED); expect($creditNote)->not->toBeNull(); expect($creditNote->getSerie())->toBe('CN-'); @@ -1990,8 +1956,19 @@ test('refundInvoice keeps the refund successful when notification delivery fails }); $em->shouldReceive('flush')->atLeast()->once(); - $eventsManager = Mockery::mock('\Box_EventManager'); - $eventsManager->shouldReceive('fire')->atLeast()->once(); + $events = []; + $eventDispatcher = new class($events) { + public function __construct(private array &$events) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $logger = new Tests\Helpers\TestLogger(); $di = container(); @@ -2000,12 +1977,17 @@ test('refundInvoice keeps the refund successful when notification delivery fails 'system' => $systemService, 'email' => $emailService, ])); - $di['events_manager'] = $eventsManager; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = $logger; $serviceMock->setDi($di); expect($serviceMock->refundInvoice($invoiceModel))->toBe($newId) ->and($invoiceModel->getStatus())->toBe(Invoice::STATUS_REFUNDED); + expect($events)->toHaveCount(2) + ->and($events[0])->toBeInstanceOf(BeforeAdminInvoiceRefundEvent::class) + ->and($events[0]->invoiceId)->toBe(9) + ->and($events[1])->toBeInstanceOf(AfterAdminInvoiceRefundEvent::class) + ->and($events[1]->invoiceId)->toBe(9); $emailErrors = array_filter( $logger->calls, @@ -2052,7 +2034,6 @@ test('refundInvoice refuses invoices that are not paid', function (): void { }); test('updates an invoice', function (): void { - $service = new Service(); $data = [ 'gateway_id' => '', 'taxname' => '', @@ -2090,9 +2071,16 @@ test('updates an invoice', function (): void { $invoiceItemModel = createEntity(InvoiceItem::class, []); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire') - ->atLeast()->once(); + $eventDispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $itemInvoiceServiceMock = Mockery::mock(ServiceInvoiceItem::class); $itemInvoiceServiceMock->shouldReceive('addNew') @@ -2112,18 +2100,20 @@ test('updates an invoice', function (): void { $di = container(); $di['em'] = $em; $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $itemInvoiceServiceMock); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); - $serviceMock->shouldReceive('toApiArray') - ->atLeast()->once() - ->andReturn(['id' => 1]); - $serviceMock->setDi($di); $result = $serviceMock->updateInvoice($invoiceModel, $data); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminInvoiceUpdateEvent::class) + ->and($eventDispatcher->events[0]->invoiceId)->toBe((int) $invoiceModel->getId()) + ->and($eventDispatcher->events[0]->changedFields)->toContain('buyer_email', 'notes') + ->and($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminInvoiceUpdateEvent::class) + ->and($eventDispatcher->events[1]->invoiceId)->toBe((int) $invoiceModel->getId()); }); test('removes an invoice', function (): void { @@ -2163,29 +2153,39 @@ test('removes an invoice', function (): void { }); test('deletes invoice by admin', function (): void { - $service = new Service(); $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); $serviceMock->shouldReceive('rmInvoice') ->once(); $invoiceModel = createEntity(Invoice::class); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire') - ->atLeast()->once(); + $eventDispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $di = container(); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); $result = $serviceMock->deleteInvoiceByAdmin($invoiceModel); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminInvoiceDeleteEvent::class) + ->and($eventDispatcher->events[0]->invoiceId)->toBe((int) $invoiceModel->getId()) + ->and($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminInvoiceDeleteEvent::class) + ->and($eventDispatcher->events[1]->invoiceId)->toBe((int) $invoiceModel->getId()); }); test('renews an invoice', function (): void { - $service = new Service(); $newId = 2; $invoiceModel = createEntity(Invoice::class); @@ -2200,17 +2200,30 @@ test('renews an invoice', function (): void { ->once() ->andReturn($invoiceModel); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire') - ->atLeast()->once(); + $eventDispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $di = container(); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); $result = $serviceMock->renewInvoice($clientOrder, []); - expect($result)->toBeInt()->toBe($newId); + expect($result)->toBeInt()->toBe($newId) + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminGenerateRenewalInvoiceEvent::class) + ->and($eventDispatcher->events[0]->orderId)->toBe((int) $clientOrder->getId()) + ->and($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminGenerateRenewalInvoiceEvent::class) + ->and($eventDispatcher->events[1]->orderId)->toBe((int) $clientOrder->getId()) + ->and($eventDispatcher->events[1]->invoiceId)->toBe($newId); }); test('processes batch pay with credits', function (): void { @@ -2761,23 +2774,27 @@ test('sends reminders in batch at most once per day', function (): void { ->with(['once_per_day' => true]) ->andReturnTrue(); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire') - ->atLeast()->once(); + $dispatchedEvents = []; + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener(BeforeAdminInvoiceSendRemindersEvent::class, static function (BeforeAdminInvoiceSendRemindersEvent $event) use (&$dispatchedEvents): void { + $dispatchedEvents[] = $event; + }); $logger = new Tests\Helpers\TestLogger(); $di = container(); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = $logger; $service->setDi($di); $result = $service->doBatchRemindersSend(); - expect($result)->toBeBool()->toBeTrue(); - expect($logger->calls)->toContain([ - 'method' => 'info', - 'params' => ['Executed action to send invoice payment reminders.'], - ]); + expect($result)->toBeTrue() + ->and($dispatchedEvents)->toHaveCount(1) + ->and($dispatchedEvents[0])->toBeInstanceOf(BeforeAdminInvoiceSendRemindersEvent::class) + ->and($logger->calls)->toContain([ + 'method' => 'info', + 'params' => ['Executed action to send invoice payment reminders.'], + ]); }); test('does not log reminder batch as executed when it is throttled', function (): void { @@ -2790,21 +2807,21 @@ test('does not log reminder batch as executed when it is throttled', function () ->once() ->andReturnFalse(); - $eventManagerMock = Mockery::mock('\\Box_EventManager'); - $eventManagerMock->shouldReceive('fire') - ->once() - ->with(['event' => 'onBeforeAdminInvoiceSendReminders']); + $dispatchedEvents = []; + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener(BeforeAdminInvoiceSendRemindersEvent::class, static function (BeforeAdminInvoiceSendRemindersEvent $event) use (&$dispatchedEvents): void { + $dispatchedEvents[] = $event; + }); $logger = new Tests\Helpers\TestLogger(); - $di = container(); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = $logger; - $service->setDi($di); expect($service->doBatchRemindersSend())->toBeFalse() - ->and($logger->calls)->toBeEmpty(); + ->and($logger->calls)->toBeEmpty() + ->and($dispatchedEvents)->toHaveCount(1); }); test('fires due events via the pending reminder fallback when the primary batch is throttled', function (): void { @@ -2829,23 +2846,26 @@ test('fires due events via the pending reminder fallback when the primary batch ->twice() ->andReturn([['id' => 2, 'days_left' => 7]], []); - $eventManagerMock = Mockery::mock('\\Box_EventManager'); - $eventManagerMock->shouldReceive('fire') - ->once() - ->with(['event' => 'onBeforeAdminInvoiceSendReminders']); - $eventManagerMock->shouldReceive('fire') - ->once() - ->with(['event' => 'onEventBeforeInvoiceIsDue', 'params' => ['id' => 2, 'days_left' => 7, 'reminder_intervals' => [7]]]); + $dispatchedEvents = []; + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener(BeforeAdminInvoiceSendRemindersEvent::class, static function (BeforeAdminInvoiceSendRemindersEvent $event) use (&$dispatchedEvents): void { + $dispatchedEvents[] = $event; + }); + $eventDispatcher->addListener(BeforeInvoiceIsDueEvent::class, static function (BeforeInvoiceIsDueEvent $event) use (&$dispatchedEvents): void { + $dispatchedEvents[] = $event; + }); $di = container(); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $systemService); $di['logger'] = new Tests\Helpers\TestLogger(); $di['em']->shouldReceive('getConnection')->andReturn($connection); - $service->setDi($di); - expect($service->doBatchRemindersSend())->toBeTrue(); + expect($service->doBatchRemindersSend())->toBeTrue() + ->and($dispatchedEvents)->toHaveCount(2) + ->and($dispatchedEvents[0])->toBeInstanceOf(BeforeAdminInvoiceSendRemindersEvent::class) + ->and($dispatchedEvents[1])->toEqual(new BeforeInvoiceIsDueEvent(2, 7, [7])); }); test('guards the primary reminder batch throttle while the fallback still dispatches', function (): void { @@ -2875,16 +2895,17 @@ test('guards the primary reminder batch throttle while the fallback still dispat ->times(4) ->andReturn([['id' => 1, 'days_left' => 7]], [], [], []); - $eventManagerMock = Mockery::mock('\\Box_EventManager'); - $eventManagerMock->shouldReceive('fire') - ->twice() - ->with(['event' => 'onBeforeAdminInvoiceSendReminders']); - $eventManagerMock->shouldReceive('fire') - ->once() - ->with(['event' => 'onEventBeforeInvoiceIsDue', 'params' => ['id' => 1, 'days_left' => 7, 'reminder_intervals' => [7]]]); + $dispatchedEvents = []; + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener(BeforeAdminInvoiceSendRemindersEvent::class, static function (BeforeAdminInvoiceSendRemindersEvent $event) use (&$dispatchedEvents): void { + $dispatchedEvents[] = $event; + }); + $eventDispatcher->addListener(BeforeInvoiceIsDueEvent::class, static function (BeforeInvoiceIsDueEvent $event) use (&$dispatchedEvents): void { + $dispatchedEvents[] = $event; + }); $di = container(); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $systemService); $di['logger'] = new Tests\Helpers\TestLogger(); $di['em']->shouldReceive('getConnection')->andReturn($connection); @@ -2896,7 +2917,11 @@ test('guards the primary reminder batch throttle while the fallback still dispat // dispatches due events (broadly, for any listener) even though the primary is throttled. expect($service->doBatchRemindersSend())->toBeTrue() ->and($service->doBatchInvokeDueEvent([]))->toBeFalse() - ->and($service->doBatchRemindersSend())->toBeTrue(); + ->and($service->doBatchRemindersSend())->toBeTrue() + ->and($dispatchedEvents)->toHaveCount(3) + ->and($dispatchedEvents[0])->toBeInstanceOf(BeforeAdminInvoiceSendRemindersEvent::class) + ->and($dispatchedEvents[1])->toEqual(new BeforeInvoiceIsDueEvent(1, 7, [7])) + ->and($dispatchedEvents[2])->toBeInstanceOf(BeforeAdminInvoiceSendRemindersEvent::class); }); test('invokes due event in batch', function (): void { @@ -2918,25 +2943,29 @@ test('invokes due event in batch', function (): void { $connection->shouldReceive('getDatabasePlatform') ->andReturn(Mockery::mock(Doctrine\DBAL\Platforms\MySQLPlatform::class)); $connection->shouldReceive('fetchAllAssociative') - ->atLeast()->once() - ->andReturn([['id' => 1]]); + ->twice() + ->andReturn([['id' => 1, 'days_left' => 7]], [['id' => 2, 'days_passed' => 5]]); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire') - ->atLeast()->once(); + $dispatchedEvents = []; + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener(BeforeInvoiceIsDueEvent::class, static function (BeforeInvoiceIsDueEvent $event) use (&$dispatchedEvents): void { + $dispatchedEvents[] = $event; + }); + $eventDispatcher->addListener(AfterInvoiceIsDueEvent::class, static function (AfterInvoiceIsDueEvent $event) use (&$dispatchedEvents): void { + $dispatchedEvents[] = $event; + }); $di = container(); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $systemService); $di['logger'] = new Tests\Helpers\TestLogger(); $di['em']->shouldReceive('getConnection')->andReturn($connection); - - $di['em']->shouldReceive('getConnection')->andReturn($connection); - $service->setDi($di); - $result = $service->doBatchInvokeDueEvent([]); - expect($result)->toBeBool()->toBeTrue(); + expect($service->doBatchInvokeDueEvent([]))->toBeTrue() + ->and($dispatchedEvents)->toHaveCount(2) + ->and($dispatchedEvents[0])->toEqual(new BeforeInvoiceIsDueEvent(1, 7, [1, 7, 14])) + ->and($dispatchedEvents[1])->toEqual(new AfterInvoiceIsDueEvent(2, 5, [5])); }); test('protects from sending reminders to paid invoices', function (): void { @@ -2956,24 +2985,61 @@ test('parses invoice reminder intervals', function (): void { ->toBe([1, 7, 14]); }); -test('sends invoice reminder', function (): void { +test('records an invoice reminder and dispatches typed events', function (): void { $service = new Service(); - $invoiceModel = createEntity(Invoice::class); + $invoiceModel = createEntity(Invoice::class, ['id' => 9]); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire') - ->atLeast()->once(); + $dispatchedEvents = []; + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener(BeforeAdminInvoiceSendReminderEvent::class, static function (BeforeAdminInvoiceSendReminderEvent $event) use (&$dispatchedEvents): void { + $dispatchedEvents[] = $event; + }); + $eventDispatcher->addListener(AfterAdminInvoiceReminderRecordedEvent::class, static function (AfterAdminInvoiceReminderRecordedEvent $event) use (&$dispatchedEvents): void { + $dispatchedEvents[] = $event; + }); $di = container(); - $di['em']->shouldReceive('persist')->atLeast()->once(); - $di['em']->shouldReceive('flush')->atLeast()->once(); - $di['events_manager'] = $eventManagerMock; + $di['em']->shouldReceive('persist')->once()->with($invoiceModel); + $di['em']->shouldReceive('flush')->once(); + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); - $service->setDi($di); - $result = $service->sendInvoiceReminder($invoiceModel); - expect($result)->toBeBool()->toBeTrue(); + expect($service->sendInvoiceReminder($invoiceModel))->toBeTrue() + ->and($dispatchedEvents)->toHaveCount(2) + ->and($dispatchedEvents[0])->toEqual(new BeforeAdminInvoiceSendReminderEvent(9)) + ->and($dispatchedEvents[1])->toEqual(new AfterAdminInvoiceReminderRecordedEvent(9)); +}); + +test('a failing reminder observer cannot prevent the built-in email attempt or retry a recorded reminder', function (): void { + $invoice = createEntity(Invoice::class, ['id' => 9]); + $calls = []; + + $events = new EventDispatcher(); + $events->addListener(AfterAdminInvoiceReminderRecordedEvent::class, static function () use (&$calls): void { + $calls[] = 'observer'; + + throw new RuntimeException('Extension listener failed'); + }, 100); + + $di = container(); + $di['em']->shouldReceive('persist')->once()->with($invoice); + $di['em']->shouldReceive('flush')->once(); + $di['event_dispatcher'] = $events; + $di['logger'] = new Tests\Helpers\TestLogger(); + + $service = Mockery::mock(Service::class)->makePartial(); + $service->shouldReceive('sendInvoiceReminderEmail') + ->once() + ->with(Mockery::on(static fn (AfterAdminInvoiceReminderRecordedEvent $event): bool => $event->invoiceId === 9)) + ->andReturnUsing(static function () use (&$calls): void { + $calls[] = 'email'; + }); + $service->setDi($di); + + expect($service->sendInvoiceReminder($invoice))->toBeTrue() + ->and($calls)->toBe(['email', 'observer']) + ->and($invoice->getRemindedAt())->toBeInstanceOf(DateTimeInterface::class); }); test('counts invoices', function (): void { @@ -3756,9 +3822,19 @@ test('markAsPaid transitions a deposit invoice to paid status', function (): voi $invoiceItemService->shouldReceive('getTotal') ->andReturn(30.0); - $eventsManager = Mockery::mock('\Box_EventManager'); - $eventsManager->shouldReceive('fire') - ->atLeast()->once(); + $events = []; + $eventDispatcher = new class($events) { + public function __construct(private array &$events) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $di = container(); $di['em'] = $em; @@ -3771,7 +3847,7 @@ test('markAsPaid transitions a deposit invoice to paid status', function (): voi ['Product', ''], ['product', ''] => $productService, default => throw new RuntimeException("Unexpected service: {$name}/{$sub}"), }); - $di['events_manager'] = $eventsManager; + $di['event_dispatcher'] = $eventDispatcher; $service->setDi($di); $result = $service->markAsPaid($depositInvoice); @@ -3779,6 +3855,9 @@ test('markAsPaid transitions a deposit invoice to paid status', function (): voi expect($result)->toBeTrue(); expect($depositInvoice->status)->toBe(Invoice::STATUS_PAID); expect($depositInvoice->paid_at)->not->toBeNull(); + expect($events)->toHaveCount(1) + ->and($events[0])->toBeInstanceOf(AfterAdminInvoicePaymentReceivedEvent::class) + ->and($events[0]->invoiceId)->toBe(89); }); test('getInvoicePdfAttachment returns null when the setting is disabled', function (): void { @@ -4286,8 +4365,19 @@ test('refundInvoice supports partial line refunds and flips to refunded at full' $serviceMock->shouldReceive('toApiArray')->andReturn(['id' => 1, 'total' => -40.0]); $serviceMock->shouldReceive('extendInvoiceHashLifetime')->twice(); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->atLeast()->once(); + $events = []; + $eventDispatcher = new class($events) { + public function __construct(private array &$events) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $systemService = Mockery::mock(SystemService::class); $systemService->shouldReceive('getParamValue') @@ -4345,17 +4435,27 @@ test('refundInvoice supports partial line refunds and flips to refunded at full' 'system' => $systemService, 'email' => $emailService, ])); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); expect($serviceMock->refundInvoice($invoiceModel, null, [12 => 1]))->toBeInt(); + expect($events)->toHaveCount(2) + ->and($events[0])->toBeInstanceOf(BeforeAdminInvoiceRefundEvent::class) + ->and($events[0]->invoiceId)->toBe(10) + ->and($events[1])->toBeInstanceOf(AfterAdminInvoiceRefundEvent::class) + ->and($events[1]->invoiceId)->toBe(10); expect($invoiceModel->getStatus())->toBe(Invoice::STATUS_PAID); expect((float) $invoiceModel->getRefund())->toEqual(40.0); expect($newEntities)->toHaveCount(1); expect($newEntities[0]->getCreditNoteForInvoiceId())->toBe(10); expect($serviceMock->refundInvoice($invoiceModel, null, [11 => 1]))->toBeInt(); + expect($events)->toHaveCount(4) + ->and($events[2])->toBeInstanceOf(BeforeAdminInvoiceRefundEvent::class) + ->and($events[2]->invoiceId)->toBe(10) + ->and($events[3])->toBeInstanceOf(AfterAdminInvoiceRefundEvent::class) + ->and($events[3]->invoiceId)->toBe(10); expect($invoiceModel->getStatus())->toBe(Invoice::STATUS_REFUNDED); expect((float) $invoiceModel->getRefund())->toEqual(100.0); expect($newEntities)->toHaveCount(2); @@ -4377,8 +4477,7 @@ test('refundInvoice validates partial refund input', function (): void { [$em, $invoiceItemRepo] = invoiceItemEmAndRepo(); $invoiceItemRepo->shouldReceive('findByInvoiceId')->andReturn([$line, $discount]); - $em->shouldReceive('persist')->atLeast()->once(); - $em->shouldReceive('flush')->atLeast()->once(); + $em->shouldNotReceive('persist', 'flush'); $invoiceRepo = Mockery::mock(InvoiceRepository::class); $invoiceRepo->shouldReceive('lockAndGetStatus')->andReturn(Invoice::STATUS_PAID); @@ -4407,13 +4506,24 @@ test('refundInvoice validates partial refund input', function (): void { $systemService->shouldReceive('getCompany') ->andReturn([]); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->atLeast()->once(); + $events = []; + $eventDispatcher = new class($events) { + public function __construct(private array &$events) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $di = container(); $di['em'] = $em; $di['mod_service'] = $di->protect(moduleService(['system' => $systemService])); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); @@ -4425,6 +4535,11 @@ test('refundInvoice validates partial refund input', function (): void { ->toThrow(FOSSBilling\InformationException::class, 'exceeds the remaining refundable quantity'); expect(fn () => $serviceMock->refundInvoice($invoiceModel, null, [11 => 0])) ->toThrow(FOSSBilling\InformationException::class, 'No invoice lines selected'); + expect($events)->toHaveCount(4); + foreach ($events as $event) { + expect($event)->toBeInstanceOf(BeforeAdminInvoiceRefundEvent::class) + ->and($event->invoiceId)->toBe(10); + } }); test('debitInvoice issues a payable debit note linked to the original', function (): void { @@ -4439,8 +4554,16 @@ test('debitInvoice issues a payable debit note linked to the original', function $serviceMock->shouldReceive('toApiArray')->andReturn(['id' => 1, 'total' => 25.0]); $serviceMock->shouldReceive('extendInvoiceHashLifetime')->once(); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $systemService = Mockery::mock(SystemService::class); $systemService->shouldReceive('getParamValue') @@ -4495,7 +4618,7 @@ test('debitInvoice issues a payable debit note linked to the original', function 'system' => $systemService, 'email' => $emailService, ])); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); @@ -4505,6 +4628,12 @@ test('debitInvoice issues a payable debit note linked to the original', function ]; expect($serviceMock->debitInvoice($invoiceModel, $items, 'customNote'))->toBe(55); expect($invoiceModel->getStatus())->toBe(Invoice::STATUS_UNPAID); + expect($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminInvoiceDebitEvent::class) + ->and($eventDispatcher->events[0]->invoiceId)->toBe(10) + ->and($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminInvoiceDebitEvent::class) + ->and($eventDispatcher->events[1]->invoiceId)->toBe(10) + ->and($eventDispatcher->events[1]->debitNoteId)->toBe(55); expect($debitNote)->not->toBeNull(); expect($debitNote->getStatus())->toBe(Invoice::STATUS_UNPAID); @@ -4614,8 +4743,16 @@ test('updateInvoice resends an approved invoice after editing it', function (): $itemInvoiceServiceMock->shouldNotReceive('addNew', 'update'); [$em] = invoiceItemEmAndRepo(); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $di = container(); $di['em'] = $em; @@ -4623,15 +4760,17 @@ test('updateInvoice resends an approved invoice after editing it', function (): 'system' => $systemMock, 'invoice:invoiceitem' => $itemInvoiceServiceMock, ])); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); - $serviceMock->shouldReceive('toApiArray')->andReturn(['id' => 10, 'total' => 50.0]); $serviceMock->shouldReceive('resendUpdatedInvoice')->once()->with($invoice); $serviceMock->setDi($di); - expect($serviceMock->updateInvoice($invoice, ['notes' => 'Edited']))->toBeTrue(); + expect($serviceMock->updateInvoice($invoice, ['notes' => 'Edited']))->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0]->changedFields)->toBe(['notes']) + ->and($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminInvoiceUpdateEvent::class); }); test('updateInvoice does not resend a draft invoice', function (): void { @@ -4643,21 +4782,31 @@ test('updateInvoice does not resend a draft invoice', function (): void { $itemInvoiceServiceMock->shouldNotReceive('addNew', 'update'); [$em] = invoiceItemEmAndRepo(); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $di = container(); $di['em'] = $em; $di['mod_service'] = $di->protect(moduleService(['invoice:invoiceitem' => $itemInvoiceServiceMock])); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); - $serviceMock->shouldReceive('toApiArray')->andReturn(['id' => 10, 'total' => 50.0]); $serviceMock->shouldNotReceive('resendUpdatedInvoice'); $serviceMock->setDi($di); - expect($serviceMock->updateInvoice($invoice, ['notes' => 'Edited']))->toBeTrue(); + expect($serviceMock->updateInvoice($invoice, ['notes' => 'Edited']))->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0]->changedFields)->toBe(['notes']) + ->and($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminInvoiceUpdateEvent::class); }); test('deleteInvoiceByAdmin only deletes unapproved unpaid invoices', function (): void { @@ -4682,16 +4831,27 @@ test('deleteInvoiceByAdmin only deletes unapproved unpaid invoices', function () $invoice->setApproved(false); $invoice->setStatus(Invoice::STATUS_UNPAID); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->twice(); + $eventDispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $di = container(); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); $serviceMock->shouldReceive('rmInvoice')->once()->with($invoice, true)->andReturn(true); - expect($serviceMock->deleteInvoiceByAdmin($invoice))->toBeTrue(); + expect($serviceMock->deleteInvoiceByAdmin($invoice))->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminInvoiceDeleteEvent::class) + ->and($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminInvoiceDeleteEvent::class); }); test('debitInvoice writes nothing when lines are invalid', function (): void { @@ -4701,10 +4861,16 @@ test('debitInvoice writes nothing when lines are invalid', function (): void { setEntityId($invoiceModel, 10); $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); - $serviceMock->shouldReceive('toApiArray')->andReturn(['id' => 10]); + $eventDispatcher = new class { + public array $events = []; - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->twice(); + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; // Strict entity manager: any write attempt fails the test. $em = Mockery::mock(EntityManagerInterface::class); @@ -4712,7 +4878,7 @@ test('debitInvoice writes nothing when lines are invalid', function (): void { $di = container(); $di['em'] = $em; - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); @@ -4720,6 +4886,9 @@ test('debitInvoice writes nothing when lines are invalid', function (): void { ->toThrow(FOSSBilling\InformationException::class, 'positive amount'); expect(fn () => $serviceMock->debitInvoice($invoiceModel, [['title' => '', 'price' => 5]])) ->toThrow(FOSSBilling\InformationException::class, 'No debit lines given'); + expect($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminInvoiceDebitEvent::class) + ->and($eventDispatcher->events[1])->toBeInstanceOf(BeforeAdminInvoiceDebitEvent::class); }); test('refundInvoice enforces per-line remaining quantities across partials', function (): void { @@ -4738,8 +4907,19 @@ test('refundInvoice enforces per-line remaining quantities across partials', fun $serviceMock->shouldReceive('toApiArray')->andReturn(['id' => 1, 'total' => -40.0]); $serviceMock->shouldReceive('extendInvoiceHashLifetime')->once(); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->atLeast()->once(); + $events = []; + $eventDispatcher = new class($events) { + public function __construct(private array &$events) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $systemService = Mockery::mock(SystemService::class); $systemService->shouldReceive('getParamValue') @@ -4784,13 +4964,21 @@ test('refundInvoice enforces per-line remaining quantities across partials', fun 'system' => $systemService, 'email' => $emailService, ])); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); expect($serviceMock->refundInvoice($invoiceModel, null, [12 => 1]))->toBeInt(); + expect($events)->toHaveCount(2) + ->and($events[0])->toBeInstanceOf(BeforeAdminInvoiceRefundEvent::class) + ->and($events[0]->invoiceId)->toBe(10) + ->and($events[1])->toBeInstanceOf(AfterAdminInvoiceRefundEvent::class) + ->and($events[1]->invoiceId)->toBe(10); expect(fn () => $serviceMock->refundInvoice($invoiceModel, null, [12 => 1])) ->toThrow(FOSSBilling\InformationException::class, 'remaining'); + expect($events)->toHaveCount(3) + ->and($events[2])->toBeInstanceOf(BeforeAdminInvoiceRefundEvent::class) + ->and($events[2]->invoiceId)->toBe(10); }); test('attachOrderToInvoice attaches an existing pending order as an order line', function (): void { @@ -4804,12 +4992,19 @@ test('attachOrderToInvoice attaches an existing pending order as an order line', setEntityId($order, 42); $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); - $serviceMock->shouldReceive('toApiArray')->andReturn(['id' => 10, 'total' => 50.0]); $serviceMock->shouldReceive('resendUpdatedInvoice')->once()->with($invoiceModel); $serviceMock->shouldReceive('addNote')->once()->with($invoiceModel, 'Order #42 attached.'); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $systemService = Mockery::mock(SystemService::class); $systemService->shouldReceive('getParamValue')->with('invoice_allow_edit_unpaid', false)->andReturn(true); @@ -4833,11 +5028,17 @@ test('attachOrderToInvoice attaches an existing pending order as an order line', 'system' => $systemService, 'invoice:invoiceitem' => $invoiceItemServiceMock, ])); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); - expect($serviceMock->attachOrderToInvoice($invoiceModel, ['order_id' => 42]))->toBe(42); + expect($serviceMock->attachOrderToInvoice($invoiceModel, ['order_id' => 42]))->toBe(42) + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminInvoiceAttachOrderEvent::class) + ->and($eventDispatcher->events[0]->invoiceId)->toBe(10) + ->and($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminInvoiceAttachOrderEvent::class) + ->and($eventDispatcher->events[1]->invoiceId)->toBe(10) + ->and($eventDispatcher->events[1]->orderId)->toBe(42); }); test('attachOrderToInvoice does not resend a draft invoice', function (): void { @@ -4853,12 +5054,19 @@ test('attachOrderToInvoice does not resend a draft invoice', function (): void { // Drafts are always editable without the opt-in setting, and sending is // left to the approval path. $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); - $serviceMock->shouldReceive('toApiArray')->andReturn(['id' => 11, 'total' => 50.0]); $serviceMock->shouldNotReceive('resendUpdatedInvoice'); $serviceMock->shouldReceive('addNote')->once()->with($invoiceModel, 'Order #42 attached.'); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $invoiceItemServiceMock = Mockery::mock(ServiceInvoiceItem::class); $invoiceItemServiceMock->shouldReceive('generateFromOrder')->once()->andReturnNull(); @@ -4878,20 +5086,33 @@ test('attachOrderToInvoice does not resend a draft invoice', function (): void { $di['mod_service'] = $di->protect(moduleService([ 'invoice:invoiceitem' => $invoiceItemServiceMock, ])); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); - expect($serviceMock->attachOrderToInvoice($invoiceModel, ['order_id' => 42]))->toBe(42); + expect($serviceMock->attachOrderToInvoice($invoiceModel, ['order_id' => 42]))->toBe(42) + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminInvoiceAttachOrderEvent::class) + ->and($eventDispatcher->events[0]->invoiceId)->toBe(11) + ->and($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminInvoiceAttachOrderEvent::class) + ->and($eventDispatcher->events[1]->invoiceId)->toBe(11) + ->and($eventDispatcher->events[1]->orderId)->toBe(42); }); test('attachOrderToInvoice refuses locked invoices and invalid orders', function (): void { // Locked invoice is rejected before any write. $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); - $serviceMock->shouldReceive('toApiArray')->andReturn(['id' => 10]); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $systemService = Mockery::mock(SystemService::class); $systemService->shouldReceive('getParamValue')->with('invoice_allow_edit_unpaid', false)->andReturn(false); @@ -4902,7 +5123,7 @@ test('attachOrderToInvoice refuses locked invoices and invalid orders', function $di = container(); $di['em'] = $em; $di['mod_service'] = $di->protect(moduleService(['system' => $systemService])); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); @@ -4955,13 +5176,18 @@ test('attachOrderToInvoice refuses locked invoices and invalid orders', function $txDi = container(); $txDi['em'] = $txEm; $txDi['mod_service'] = $txDi->protect(moduleService(['invoice:invoiceitem' => $itemService])); - $txDi['events_manager'] = $eventManagerMock; + $txDi['event_dispatcher'] = $eventDispatcher; $txDi['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($txDi); expect(fn () => $serviceMock->attachOrderToInvoice($editable, ['order_id' => 42])) ->toThrow(FOSSBilling\InformationException::class, $message); } + + expect($eventDispatcher->events)->toHaveCount(7); + foreach ($eventDispatcher->events as $event) { + expect($event)->toBeInstanceOf(BeforeAdminInvoiceAttachOrderEvent::class); + } }); test('reissueInvoice cancels the original and moves its lines to a numbered replacement', function (): void { @@ -5010,12 +5236,19 @@ test('reissueInvoice cancels the original and moves its lines to a numbered repl setEntityId($naked, 44); $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); - $serviceMock->shouldReceive('toApiArray')->andReturn(['id' => 10, 'total' => 90.0]); $serviceMock->shouldReceive('resendUpdatedInvoice')->once(); $serviceMock->shouldReceive('addNote')->twice(); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $systemService = Mockery::mock(SystemService::class); $systemService->shouldReceive('reserveNextNumericParamValue')->with('invoice_starting_number')->andReturn(8); @@ -5068,12 +5301,18 @@ test('reissueInvoice cancels the original and moves its lines to a numbered repl 'product' => $productService, 'order' => $orderService, ])); - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); $serviceMock->shouldReceive('getInvoiceItemRepository')->andReturn($invoiceItemRepo); - expect($serviceMock->reissueInvoice($original, []))->toBe(11); + expect($serviceMock->reissueInvoice($original, []))->toBe(11) + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminInvoiceReissueEvent::class) + ->and($eventDispatcher->events[0]->invoiceId)->toBe(10) + ->and($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminInvoiceReissueEvent::class) + ->and($eventDispatcher->events[1]->invoiceId)->toBe(10) + ->and($eventDispatcher->events[1]->replacementInvoiceId)->toBe(11); expect($original->getStatus())->toBe(Invoice::STATUS_CANCELED); expect($original->getReplacedByInvoiceId())->toBe(11); @@ -5089,17 +5328,24 @@ test('reissueInvoice cancels the original and moves its lines to a numbered repl test('reissueInvoice refuses drafts, paid invoices, notes, and double reissue', function (): void { $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); - $serviceMock->shouldReceive('toApiArray')->andReturn(['id' => 10]); - $eventManagerMock = Mockery::mock('\Box_EventManager'); - $eventManagerMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new class { + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } + }; $em = Mockery::mock(EntityManagerInterface::class); $em->shouldNotReceive('wrapInTransaction', 'persist', 'flush'); $di = container(); $di['em'] = $em; - $di['events_manager'] = $eventManagerMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $serviceMock->setDi($di); @@ -5132,4 +5378,10 @@ test('reissueInvoice refuses drafts, paid invoices, notes, and double reissue', setEntityId($already, 10); expect(fn () => $serviceMock->reissueInvoice($already)) ->toThrow(FOSSBilling\InformationException::class, 'already been reissued'); + + expect($eventDispatcher->events)->toHaveCount(4); + foreach ($eventDispatcher->events as $event) { + expect($event)->toBeInstanceOf(BeforeAdminInvoiceReissueEvent::class) + ->and($event->invoiceId)->toBe(10); + } }); diff --git a/src/modules/Invoice/tests/Unit/ServiceTransactionTest.php b/src/modules/Invoice/tests/Unit/ServiceTransactionTest.php index a911ef31f..9836cf95b 100644 --- a/src/modules/Invoice/tests/Unit/ServiceTransactionTest.php +++ b/src/modules/Invoice/tests/Unit/ServiceTransactionTest.php @@ -15,6 +15,11 @@ use Box\Mod\Invoice\Entity\Invoice; use Box\Mod\Invoice\Entity\PayGateway; use Box\Mod\Invoice\Entity\Subscription; use Box\Mod\Invoice\Entity\Transaction; +use Box\Mod\Invoice\Event\AfterAdminTransactionCreateEvent; +use Box\Mod\Invoice\Event\AfterAdminTransactionProcessEvent; +use Box\Mod\Invoice\Event\AfterAdminTransactionUpdateEvent; +use Box\Mod\Invoice\Event\BeforeAdminTransactionCreateEvent; +use Box\Mod\Invoice\Event\BeforeAdminTransactionUpdateEvent; use Box\Mod\Invoice\Repository\InvoiceRepository; use Box\Mod\Invoice\Repository\PayGatewayRepository; use Box\Mod\Invoice\Repository\SubscriptionRepository; @@ -22,6 +27,7 @@ use Box\Mod\Invoice\Repository\TransactionRepository; use Box\Mod\Invoice\ServiceSubscription; use Box\Mod\Invoice\ServiceTransaction; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher; use function Tests\Helpers\container; use function Tests\Helpers\createEntity; @@ -51,12 +57,11 @@ test('gets dependency injection container', function (): void { }); test('updates a transaction', function (): void { - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire') - ->atLeast()->once(); - $em = Mockery::mock(EntityManagerInterface::class); - $em->shouldReceive('flush')->atLeast()->once(); + $flushed = false; + $em->shouldReceive('flush')->once()->andReturnUsing(function () use (&$flushed): void { + $flushed = true; + }); $invoice = createEntity(Invoice::class, ['id' => 1]); $gateway = createEntity(PayGateway::class, ['id' => 1]); @@ -68,11 +73,19 @@ test('updates a transaction', function (): void { $em->shouldReceive('getRepository')->with(Invoice::class)->andReturn($invoiceRepository); - $service = transactionService(payGatewayRepo: $payGatewayRepository, em: $em); - $service->getDi()['events_manager'] = $eventsMock; - $service->getDi()['logger'] = new Tests\Helpers\TestLogger(); - $transactionModel = createEntity(Transaction::class, ['id' => 1]); + $events = []; + $dispatcher = new SymfonyEventDispatcher(); + $dispatcher->addListener(BeforeAdminTransactionUpdateEvent::class, static function (BeforeAdminTransactionUpdateEvent $event) use (&$events, $transactionModel): void { + $events[] = [$event, $transactionModel->getTxnId(), false]; + }); + $dispatcher->addListener(AfterAdminTransactionUpdateEvent::class, static function (AfterAdminTransactionUpdateEvent $event) use (&$events, $transactionModel, &$flushed): void { + $events[] = [$event, $transactionModel->getTxnId(), $flushed]; + }); + + $service = transactionService(payGatewayRepo: $payGatewayRepository, em: $em); + $service->getDi()['event_dispatcher'] = $dispatcher; + $service->getDi()['logger'] = new Tests\Helpers\TestLogger(); $data = [ 'invoice_id' => 1, @@ -87,7 +100,16 @@ test('updates a transaction', function (): void { 'validate_ipn' => '', ]; $result = $service->update($transactionModel, $data); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($events)->toHaveCount(2) + ->and($events[0][0])->toBeInstanceOf(BeforeAdminTransactionUpdateEvent::class) + ->and($events[0][0]->transactionId)->toBe(1) + ->and($events[0][1])->toBeNull() + ->and($events[0][2])->toBeFalse() + ->and($events[1][0])->toBeInstanceOf(AfterAdminTransactionUpdateEvent::class) + ->and($events[1][0]->transactionId)->toBe(1) + ->and($events[1][1])->toBe('2') + ->and($events[1][2])->toBeTrue(); }); test('updates a transaction subscription link', function (): void { @@ -108,28 +130,26 @@ test('updates a transaction subscription link', function (): void { }); test('throws exception when creating transaction with missing invoice id', function (): void { - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire') - ->atLeast()->once(); - $service = transactionService(); - $service->getDi()['events_manager'] = $eventsMock; + $events = []; + $dispatcher = new SymfonyEventDispatcher(); + $dispatcher->addListener(BeforeAdminTransactionCreateEvent::class, static function (BeforeAdminTransactionCreateEvent $event) use (&$events): void { + $events[] = $event; + }); + $service->getDi()['event_dispatcher'] = $dispatcher; $data = [ 'skip_validation' => false, ]; expect(fn (): ?int => $service->create($data)) - ->toThrow(FOSSBilling\Exception::class, 'Transaction invoice ID is missing'); + ->toThrow(FOSSBilling\Exception::class, 'Transaction invoice ID is missing') + ->and($events)->toHaveCount(1) + ->and($events[0]->input)->toBe(['skip_validation' => false]); }); test('throws exception when creating transaction with missing gateway id', function (): void { - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire') - ->atLeast()->once(); - $service = transactionService(); - $service->getDi()['events_manager'] = $eventsMock; $data = [ 'skip_validation' => false, @@ -140,6 +160,58 @@ test('throws exception when creating transaction with missing gateway id', funct ->toThrow(FOSSBilling\Exception::class, 'Payment gateway ID is missing'); }); +test('creates a transaction with safe lifecycle events and excludes raw IPN and credential input', function (): void { + $transactionRepository = Mockery::mock(TransactionRepository::class); + $payGatewayRepository = Mockery::mock(PayGatewayRepository::class); + $em = Mockery::mock(EntityManagerInterface::class); + $flushed = false; + $em->shouldReceive('persist')->once()->with(Mockery::on(static function (Transaction $transaction): bool { + setEntityId($transaction, 27); + + return true; + })); + $em->shouldReceive('flush')->once()->andReturnUsing(function () use (&$flushed): void { + $flushed = true; + }); + + $events = []; + $dispatcher = new SymfonyEventDispatcher(); + $dispatcher->addListener(BeforeAdminTransactionCreateEvent::class, static function (BeforeAdminTransactionCreateEvent $event) use (&$events, &$flushed): void { + $events[] = [$event, $flushed]; + }); + $dispatcher->addListener(AfterAdminTransactionCreateEvent::class, static function (AfterAdminTransactionCreateEvent $event) use (&$events, &$flushed): void { + $events[] = [$event, $flushed]; + }); + + $service = transactionService($transactionRepository, $payGatewayRepository, $em); + $service->getDi()['event_dispatcher'] = $dispatcher; + + $input = [ + 'skip_validation' => true, + 'source' => 'admin', + 'txn_id' => 'tx_safe_ref', + 'post' => ['token' => 'secret-post-token'], + 'get' => ['api_key' => 'secret-query-key'], + 'server' => ['REMOTE_ADDR' => '192.0.2.1'], + 'http_raw_post_data' => 'raw-secret-payload', + 'password' => 'secret-password', + 'config' => ['secret' => 'gateway-credential'], + ]; + + expect($service->create($input))->toBe(27) + ->and($events)->toHaveCount(2) + ->and($events[0][0])->toBeInstanceOf(BeforeAdminTransactionCreateEvent::class) + ->and($events[0][0]->input)->toBe([ + 'skip_validation' => true, + 'source' => 'admin', + 'txn_id' => 'tx_safe_ref', + ]) + ->and($events[0][1])->toBeFalse() + ->and($events[1][0])->toBeInstanceOf(AfterAdminTransactionCreateEvent::class) + ->and($events[1][0]->transactionId)->toBe(27) + ->and($events[1][1])->toBeTrue(); +}); + test('deletes a transaction', function (): void { $em = Mockery::mock(EntityManagerInterface::class); $em->shouldReceive('remove')->atLeast()->once(); @@ -295,32 +367,39 @@ test('createAndProcess skips processing when transaction is already processed', test('preProcessTransaction returns a boolean result', function (): void { $transactionModel = createEntity(Transaction::class, ['id' => 5]); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire')->atLeast()->once(); + $events = []; + $dispatcher = new SymfonyEventDispatcher(); + $dispatcher->addListener(AfterAdminTransactionProcessEvent::class, static function (AfterAdminTransactionProcessEvent $event) use (&$events): void { + $events[] = $event; + }); $di = container(); - $di['events_manager'] = $eventsMock; + $di['event_dispatcher'] = $dispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $service = Mockery::mock(ServiceTransaction::class)->makePartial(); $service->shouldReceive('processTransaction') ->with(5) ->once() - ->andReturn(1); + ->andReturnUsing(function () use (&$events): int { + $events[] = 'processed'; + + return 1; + }); $service->setDi($di); $result = $service->preProcessTransaction($transactionModel); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($events)->toHaveCount(2) + ->and($events[0])->toBe('processed') + ->and($events[1])->toBeInstanceOf(AfterAdminTransactionProcessEvent::class) + ->and($events[1]->transactionId)->toBe(5); }); test('preProcessTransaction returns true when the adapter returns nothing', function (): void { $transactionModel = createEntity(Transaction::class, ['id' => 5]); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire')->atLeast()->once(); - $di = container(); - $di['events_manager'] = $eventsMock; $di['logger'] = new Tests\Helpers\TestLogger(); $service = Mockery::mock(ServiceTransaction::class)->makePartial(); @@ -347,12 +426,15 @@ test('preProcessTransaction marks error on a generic exception', function (): vo $em->shouldReceive('flush')->once(); $em->shouldReceive('refresh')->with($transactionModel)->once(); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldNotReceive('fire'); + $events = []; + $dispatcher = new SymfonyEventDispatcher(); + $dispatcher->addListener(AfterAdminTransactionProcessEvent::class, static function (AfterAdminTransactionProcessEvent $event) use (&$events): void { + $events[] = $event; + }); $di = container(); $di['em'] = $em; - $di['events_manager'] = $eventsMock; + $di['event_dispatcher'] = $dispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $service = Mockery::mock(ServiceTransaction::class)->makePartial(); @@ -372,7 +454,8 @@ test('preProcessTransaction marks error on a generic exception', function (): vo expect($thrown)->toBeInstanceOf(RuntimeException::class) ->and($transactionModel->getStatus())->toBe(Transaction::STATUS_ERROR) - ->and($transactionModel->getError())->toBe('Unexpected DB error'); + ->and($transactionModel->getError())->toBe('Unexpected DB error') + ->and($events)->toBeEmpty(); }); test('processes the rest of a received transaction batch after a failure', function (): void { @@ -483,12 +566,8 @@ test('_subscribe creates and persists a subscription from an approved transactio }); $em->shouldReceive('flush')->atLeast()->once(); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire'); - $di = container(); $di['em'] = $em; - $di['events_manager'] = $eventsMock; $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod_service'] = $di->protect(fn ($module, $sub = '') => $subscriptionService); @@ -539,12 +618,8 @@ test('_unsubscribe looks up the subscription by sid and delegates to the subscri $em->shouldReceive('getRepository')->with(Subscription::class)->andReturn($subscriptionRepo); $em->shouldReceive('flush')->atLeast()->once(); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire'); - $di = container(); $di['em'] = $em; - $di['events_manager'] = $eventsMock; $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod_service'] = $di->protect(fn ($module, $sub = '') => $subscriptionService); diff --git a/src/modules/Notification/Event/AfterAdminNotificationAddEvent.php b/src/modules/Notification/Event/AfterAdminNotificationAddEvent.php new file mode 100644 index 000000000..9926947ea --- /dev/null +++ b/src/modules/Notification/Event/AfterAdminNotificationAddEvent.php @@ -0,0 +1,20 @@ +di['events_manager']->fire(['event' => 'onAfterAdminNotificationAdd', 'params' => ['id' => $id]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminNotificationAddEvent($id)); return $id; } diff --git a/src/modules/Notification/tests/Unit/ServiceTest.php b/src/modules/Notification/tests/Unit/ServiceTest.php index 004cadd66..bc2d5ee2b 100644 --- a/src/modules/Notification/tests/Unit/ServiceTest.php +++ b/src/modules/Notification/tests/Unit/ServiceTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); use Box\Mod\Extension\Entity\ExtensionMeta; use Box\Mod\Extension\Repository\ExtensionMetaRepository; +use Box\Mod\Notification\Event\AfterAdminNotificationAddEvent; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\QueryBuilder; @@ -92,3 +93,44 @@ test('sorts notification search query', function (array $filter, string $expecte 'invalid sort falls back to default' => [['sort' => 'n.metaValue; DROP TABLE extension_meta'], 'n.id', 'DESC', null], 'invalid direction falls back to ascending' => [['sort' => 'created_at', 'direction' => 'sideways'], 'n.createdAt', 'ASC', 'ASC'], ]); + +test('create dispatches the typed event after persisting the notification', function (): void { + $calls = (object) ['steps' => [], 'events' => []]; + $eventDispatcher = new readonly class($calls) { + public function __construct(private object $calls) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->calls->steps[] = 'typed'; + $this->calls->events[] = $event; + + return $event; + } + }; + + $repository = Mockery::mock(ExtensionMetaRepository::class)->makePartial()->shouldIgnoreMissing(); + $entityManager = Mockery::mock(EntityManagerInterface::class); + $entityManager->shouldReceive('getRepository')->with(ExtensionMeta::class)->once()->andReturn($repository); + $entityManager->shouldReceive('persist')->once()->with(Mockery::type(ExtensionMeta::class))->andReturnUsing(function (ExtensionMeta $meta) use ($calls): void { + $calls->steps[] = 'persist'; + (new ReflectionProperty(ExtensionMeta::class, 'id'))->setValue($meta, 42); + }); + $entityManager->shouldReceive('flush')->once()->andReturnUsing(function () use ($calls): void { + $calls->steps[] = 'flush'; + }); + + $di = new Pimple\Container(); + $di['em'] = $entityManager; + $di['event_dispatcher'] = $eventDispatcher; + + $service = new Box\Mod\Notification\Service(); + $service->setDi($di); + + expect($service->create('Maintenance tonight'))->toBe(42) + ->and($calls->steps)->toBe(['persist', 'flush', 'typed']) + ->and($calls->events)->toHaveCount(1) + ->and($calls->events[0])->toBeInstanceOf(AfterAdminNotificationAddEvent::class) + ->and($calls->events[0]->notificationId)->toBe(42); +}); diff --git a/src/modules/Order/Api/Admin.php b/src/modules/Order/Api/Admin.php index 4bae5124f..d691f81f4 100644 --- a/src/modules/Order/Api/Admin.php +++ b/src/modules/Order/Api/Admin.php @@ -194,7 +194,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi * Suspend order. * * @optional string $reason - Suspension reason message - * @optional bool $skip_event - Skip calling event hooks + * @optional bool $skip_event - Skip dispatching lifecycle events * * @return bool */ @@ -230,7 +230,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi /** * Cancel order. * - * @optional bool $skip_event - Skip calling event hooks + * @optional bool $skip_event - Skip dispatching lifecycle events * @optional bool $cancel_at_period_end - Keep the order active until its gateway subscription ends * * @return bool diff --git a/src/modules/Order/Event/AfterAdminBatchCancelSuspendedOrdersEvent.php b/src/modules/Order/Event/AfterAdminBatchCancelSuspendedOrdersEvent.php new file mode 100644 index 000000000..1409db12c --- /dev/null +++ b/src/modules/Order/Event/AfterAdminBatchCancelSuspendedOrdersEvent.php @@ -0,0 +1,17 @@ + $resultParameters Additional template parameters returned by the service action. */ + public function __construct( + public readonly int $orderId, + public readonly array $resultParameters = [], + ) { + } +} diff --git a/src/modules/Order/Event/AfterAdminOrderCancelEvent.php b/src/modules/Order/Event/AfterAdminOrderCancelEvent.php new file mode 100644 index 000000000..2e19f6b51 --- /dev/null +++ b/src/modules/Order/Event/AfterAdminOrderCancelEvent.php @@ -0,0 +1,20 @@ + $input */ + public function __construct( + public readonly int $clientId, + public readonly int $productId, + public readonly string $serviceType, + public readonly array $input, + ) { + } +} diff --git a/src/modules/Order/Event/BeforeAdminOrderDeleteEvent.php b/src/modules/Order/Event/BeforeAdminOrderDeleteEvent.php new file mode 100644 index 000000000..603bfe9ad --- /dev/null +++ b/src/modules/Order/Event/BeforeAdminOrderDeleteEvent.php @@ -0,0 +1,20 @@ + $input */ + public function __construct( + public readonly int $orderId, + public readonly array $input, + ) { + } +} diff --git a/src/modules/Order/Service.php b/src/modules/Order/Service.php index 88cd949da..98cf3939f 100644 --- a/src/modules/Order/Service.php +++ b/src/modules/Order/Service.php @@ -17,6 +17,32 @@ use Box\Mod\Invoice\Entity\Invoice; use Box\Mod\Order\Entity\Order; use Box\Mod\Order\Entity\OrderMeta; use Box\Mod\Order\Entity\OrderStatus; +use Box\Mod\Order\Event\AfterAdminBatchCancelSuspendedOrdersEvent; +use Box\Mod\Order\Event\AfterAdminBatchCancelUnpaidOrdersEvent; +use Box\Mod\Order\Event\AfterAdminBatchSendSuspensionWarningsEvent; +use Box\Mod\Order\Event\AfterAdminBatchSuspendOrdersEvent; +use Box\Mod\Order\Event\AfterAdminOrderActivateEvent; +use Box\Mod\Order\Event\AfterAdminOrderCancelEvent; +use Box\Mod\Order\Event\AfterAdminOrderCreateEvent; +use Box\Mod\Order\Event\AfterAdminOrderDeleteEvent; +use Box\Mod\Order\Event\AfterAdminOrderRenewEvent; +use Box\Mod\Order\Event\AfterAdminOrderSuspendEvent; +use Box\Mod\Order\Event\AfterAdminOrderUncancelEvent; +use Box\Mod\Order\Event\AfterAdminOrderUnsuspendEvent; +use Box\Mod\Order\Event\AfterAdminOrderUpdateEvent; +use Box\Mod\Order\Event\BeforeAdminBatchCancelSuspendedOrdersEvent; +use Box\Mod\Order\Event\BeforeAdminBatchCancelUnpaidOrdersEvent; +use Box\Mod\Order\Event\BeforeAdminBatchSendSuspensionWarningsEvent; +use Box\Mod\Order\Event\BeforeAdminBatchSuspendOrdersEvent; +use Box\Mod\Order\Event\BeforeAdminOrderActivateEvent; +use Box\Mod\Order\Event\BeforeAdminOrderCancelEvent; +use Box\Mod\Order\Event\BeforeAdminOrderCreateEvent; +use Box\Mod\Order\Event\BeforeAdminOrderDeleteEvent; +use Box\Mod\Order\Event\BeforeAdminOrderRenewEvent; +use Box\Mod\Order\Event\BeforeAdminOrderSuspendEvent; +use Box\Mod\Order\Event\BeforeAdminOrderUncancelEvent; +use Box\Mod\Order\Event\BeforeAdminOrderUnsuspendEvent; +use Box\Mod\Order\Event\BeforeAdminOrderUpdateEvent; use Box\Mod\Order\Repository\OrderMetaRepository; use Box\Mod\Order\Repository\OrderRepository; use Box\Mod\Order\Repository\OrderStatusRepository; @@ -29,6 +55,7 @@ use FOSSBilling\Logger; use FOSSBilling\SortOptions; use FOSSBilling\Validation\NonNegativeIntegerValidator; use FOSSBilling\Validation\PriceValidator; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpFoundation\Response; class Service implements InjectionAwareInterface @@ -52,6 +79,18 @@ class Service implements InjectionAwareInterface 'period', 'quantity', 'price', 'discount', 'status', 'reason', 'notes', ]; + /** Fields safe to expose in a typed create event. Provisioning config and payment details are excluded. */ + private const array CREATE_EVENT_INPUT_FIELDS = [ + 'quantity', 'price', 'currency', 'period', 'group_id', 'title', 'activate', + 'invoice_option', 'mark_invoice_paid', 'created_at', 'updated_at', 'promo_id', 'notes', + ]; + + /** Fields safe to expose in a typed update event. Metadata may contain secrets and is excluded. */ + private const array UPDATE_EVENT_INPUT_FIELDS = [ + 'period', 'created_at', 'activated_at', 'expires_at', 'invoice_option', 'title', + 'price', 'status', 'notes', 'reason', 'suspension_grace_days', + ]; + public const META_CANCEL_AT_PERIOD_END = 'cancel_at_period_end'; private const string META_SUSPENSION_WARNING_FOR = 'suspension_warning_for'; @@ -108,6 +147,16 @@ class Service implements InjectionAwareInterface return (int) $order->getId(); } + /** + * @param list $allowedFields + * + * @return array + */ + private function filterOrderEventInput(array $input, array $allowedFields): array + { + return array_intersect_key($input, array_flip($allowedFields)); + } + private function persistOrder(Order $order): void { $this->di['em']->persist($order); @@ -158,40 +207,36 @@ class Service implements InjectionAwareInterface ]; } - public static function onAfterAdminOrderActivate(\Box_Event $event): void + #[AsEventListener] + public function sendOrderActivationEmail(AfterAdminOrderActivateEvent $event): void { - $params = $event->getParameters(); - $order_id = $params['id']; - $di = $event->getDi(); - $service = $di['mod_service']('order'); + $di = $this->di ?? throw new \LogicException('Order service must be initialized before handling events.'); + $orderId = $event->orderId; try { - $order = $di['em']->getRepository(Order::class)->find($order_id); + $order = $di['em']->getRepository(Order::class)->find($orderId); if (!$order instanceof Order) { throw new \FOSSBilling\Exception('Order not found'); } - $s = $service->getOrderServiceData($order); - $orderArr = $service->toApiArray($order, true); + $service = $this->getOrderServiceData($order); + $orderArr = $this->toApiArray($order, true); - $email = $params; + $email = ['id' => $orderId, ...$event->resultParameters]; $email['to_client'] = $order->getClientId(); $email['code'] = sprintf('mod_service%s_activated', $orderArr['service_type']); - $email['service'] = $s; + $email['service'] = $service; $email['order'] = $orderArr; $emailService = $di['mod_service']('email'); $emailService->sendTemplate($email); } catch (\Exception $exc) { - $di['logger']->withChannel('email')->error('Failed to send order activation email', ['exception' => $exc, 'order_id' => $order_id]); + $di['logger']->withChannel('email')->error('Failed to send order activation email', ['exception' => $exc, 'order_id' => $orderId]); } } - private static function sendOrderLifecycleEmail(\Box_Event $event, string $templateSuffix, string $logAction, bool $includeService = true): void + private function sendOrderLifecycleEmail(int $orderId, string $templateSuffix, string $logAction, bool $includeService = true): void { - $params = $event->getParameters(); - $orderId = $params['id']; - $di = $event->getDi(); - $orderService = $di['mod_service']('order'); + $di = $this->di ?? throw new \LogicException('Order service must be initialized before handling events.'); try { $order = $di['em']->getRepository(Order::class)->find($orderId); @@ -199,8 +244,8 @@ class Service implements InjectionAwareInterface throw new \FOSSBilling\Exception('Order not found'); } - $service = $includeService ? $orderService->getOrderServiceData($order) : null; - $orderArr = $orderService->toApiArray($order, true); + $service = $includeService ? $this->getOrderServiceData($order) : null; + $orderArr = $this->toApiArray($order, true); $email = [ 'to_client' => $orderArr['client']['id'], @@ -218,29 +263,34 @@ class Service implements InjectionAwareInterface } } - public static function onAfterAdminOrderRenew(\Box_Event $event): void + #[AsEventListener] + public function sendOrderRenewalEmail(AfterAdminOrderRenewEvent $event): void { - self::sendOrderLifecycleEmail($event, 'renewed', 'renewal'); + $this->sendOrderLifecycleEmail($event->orderId, 'renewed', 'renewal'); } - public static function onAfterAdminOrderSuspend(\Box_Event $event): void + #[AsEventListener] + public function sendOrderSuspensionEmail(AfterAdminOrderSuspendEvent $event): void { - self::sendOrderLifecycleEmail($event, 'suspended', 'suspension'); + $this->sendOrderLifecycleEmail($event->orderId, 'suspended', 'suspension'); } - public static function onAfterAdminOrderUnsuspend(\Box_Event $event): void + #[AsEventListener] + public function sendOrderUnsuspensionEmail(AfterAdminOrderUnsuspendEvent $event): void { - self::sendOrderLifecycleEmail($event, 'unsuspended', 'unsuspension'); + $this->sendOrderLifecycleEmail($event->orderId, 'unsuspended', 'unsuspension'); } - public static function onAfterAdminOrderCancel(\Box_Event $event): void + #[AsEventListener] + public function sendOrderCancellationEmail(AfterAdminOrderCancelEvent $event): void { - self::sendOrderLifecycleEmail($event, 'canceled', 'cancellation', false); + $this->sendOrderLifecycleEmail($event->orderId, 'canceled', 'cancellation', false); } - public static function onAfterAdminOrderUncancel(\Box_Event $event): void + #[AsEventListener] + public function sendOrderUncancelEmail(AfterAdminOrderUncancelEvent $event): void { - self::sendOrderLifecycleEmail($event, 'renewed', 'uncancel'); + $this->sendOrderLifecycleEmail($event->orderId, 'renewed', 'uncancel'); } /** @@ -808,7 +858,12 @@ class Service implements InjectionAwareInterface throw new \FOSSBilling\Exception('Currency could not be determined for order'); } - $this->di['events_manager']->fire(['event' => 'onBeforeAdminOrderCreate', 'params' => $data, 'subject' => $this->getProductType($product)]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminOrderCreateEvent( + (int) $client->getId(), + $this->getProductId($product), + $this->getProductType($product), + $this->filterOrderEventInput($data, self::CREATE_EVENT_INPUT_FIELDS), + )); $period = (isset($data['period']) && !empty($data['period'])) ? $data['period'] : null; $config = (isset($data['config']) && is_array($data['config'])) ? $data['config'] : []; @@ -1083,7 +1138,12 @@ class Service implements InjectionAwareInterface throw new \FOSSBilling\Exception('Order not found'); } - $this->di['events_manager']->fire(['event' => 'onAfterAdminOrderCreate', 'params' => ['id' => $order->getId()], 'subject' => $this->getProductType($product)]); + $this->di['event_dispatcher']->dispatch(new AfterAdminOrderCreateEvent( + $this->orderId($order), + (int) $client->getId(), + $this->getProductId($product), + $this->getProductType($product), + )); $this->di['logger']->info('Created order #{id}', ['id' => $id]); @@ -1121,9 +1181,9 @@ class Service implements InjectionAwareInterface $addonId = $this->orderId($addon); try { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminOrderActivate', 'params' => ['id' => $addonId]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminOrderActivateEvent($addonId)); $this->createFromOrder($addon); - $this->di['events_manager']->fire(['event' => 'onAfterAdminOrderActivate', 'params' => ['id' => $addonId]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminOrderActivateEvent($addonId)); } catch (\Exception $e) { $this->di['logger']->info($e->getMessage()); } @@ -1154,13 +1214,9 @@ class Service implements InjectionAwareInterface throw new \FOSSBilling\Exception('Only pending setup or failed orders can be activated'); } - $event_params = ['id' => $orderId]; - $this->di['events_manager']->fire(['event' => 'onBeforeAdminOrderActivate', 'params' => $event_params]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminOrderActivateEvent($orderId)); $result = $this->createFromOrder($order); - if (is_array($result)) { - $event_params = [...$event_params, ...$result]; - } - $this->di['events_manager']->fire(['event' => 'onAfterAdminOrderActivate', 'params' => $event_params]); + $this->di['event_dispatcher']->dispatch(new AfterAdminOrderActivateEvent($orderId, is_array($result) ? $result : [])); $this->activateOrderAddons($order); @@ -1351,7 +1407,10 @@ class Service implements InjectionAwareInterface public function updateOrder(Order $order, array $data): bool { $orderId = $this->orderId($order); - $this->di['events_manager']->fire(['event' => 'onBeforeAdminOrderUpdate', 'params' => $data]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminOrderUpdateEvent( + $orderId, + $this->filterOrderEventInput($data, self::UPDATE_EVENT_INPUT_FIELDS), + )); $this->updatePeriod($order, $data['period'] ?? null); $created_at = $data['created_at'] ?? ''; @@ -1411,7 +1470,7 @@ class Service implements InjectionAwareInterface $order->setUpdatedAt(new \DateTime()); $this->persistOrder($order); - $this->di['events_manager']->fire(['event' => 'onAfterAdminOrderUpdate', 'params' => ['id' => $orderId]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminOrderUpdateEvent($orderId)); $this->di['logger']->info('Update order #{order_id}', ['order_id' => $orderId]); @@ -1421,7 +1480,7 @@ class Service implements InjectionAwareInterface public function renewOrder(Order $order): bool { $orderId = $this->orderId($order); - $this->di['events_manager']->fire(['event' => 'onBeforeAdminOrderRenew', 'params' => ['id' => $orderId]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminOrderRenewEvent($orderId)); $this->renewFromOrder($order); @@ -1438,7 +1497,7 @@ class Service implements InjectionAwareInterface } } - $this->di['events_manager']->fire(['event' => 'onAfterAdminOrderRenew', 'params' => ['id' => $orderId]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminOrderRenewEvent($orderId)); $this->di['logger']->info('Renewed order #{order_id}', ['order_id' => $orderId]); return true; @@ -1496,7 +1555,7 @@ class Service implements InjectionAwareInterface $orderStatus = $order->getStatus(); if (!$skipEvent) { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminOrderSuspend', 'params' => ['id' => $orderId]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminOrderSuspendEvent($orderId)); } if ($orderStatus != Order::STATUS_ACTIVE) { @@ -1516,7 +1575,7 @@ class Service implements InjectionAwareInterface $this->saveStatusChange($order, $note); if (!$skipEvent) { - $this->di['events_manager']->fire(['event' => 'onAfterAdminOrderSuspend', 'params' => ['id' => $orderId]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminOrderSuspendEvent($orderId)); } $this->di['logger']->info('Suspended order #{order_id}', ['order_id' => $orderId]); @@ -1527,7 +1586,7 @@ class Service implements InjectionAwareInterface public function unsuspendFromOrder(Order $order): bool { $orderId = $this->orderId($order); - $this->di['events_manager']->fire(['event' => 'onBeforeAdminOrderUnsuspend', 'params' => ['id' => $orderId]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminOrderUnsuspendEvent($orderId)); $this->_callOnService($order, Order::ACTION_UNSUSPEND); @@ -1540,7 +1599,7 @@ class Service implements InjectionAwareInterface $this->saveStatusChange($order, 'Order unsuspended'); - $this->di['events_manager']->fire(['event' => 'onAfterAdminOrderUnsuspend', 'params' => ['id' => $orderId]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminOrderUnsuspendEvent($orderId)); $this->di['logger']->info('Unsuspended order #{order_id}', ['order_id' => $orderId]); @@ -1566,7 +1625,7 @@ class Service implements InjectionAwareInterface $this->saveStatusChange($order, $note); if (!$skipEvent) { - $this->di['events_manager']->fire(['event' => 'onAfterAdminOrderCancel', 'params' => ['id' => $orderId]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminOrderCancelEvent($orderId)); } $this->di['logger']->info('Canceled order #{order_id}', ['order_id' => $orderId]); @@ -1600,11 +1659,21 @@ class Service implements InjectionAwareInterface public function finalizeCancellationFromGateway(Order $order, $reason = null): bool { + $orderId = $this->orderId($order); $this->assertOrderCanBeCanceled($order); $this->beginCancellation($order, false); $this->completeCancellation($order, $reason, false); + $productService = $this->di['mod_service']('Product'); + $productService->releaseReservedPromoRedemptionsForOrder($order, 'order_canceled'); + $productService->releaseReservedStockForOrder($order, 'order_canceled'); + + $note = ($reason === null) ? 'Order canceled' : 'Canceled order for ' . $reason; + $this->saveStatusChange($order, $note); + + $this->di['event_dispatcher']->dispatch(new AfterAdminOrderCancelEvent($orderId)); + return true; } @@ -1622,7 +1691,7 @@ class Service implements InjectionAwareInterface { $orderId = $this->orderId($order); if (!$skipEvent) { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminOrderCancel', 'params' => ['id' => $orderId]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminOrderCancelEvent($orderId)); } $this->_callOnService($order, Order::ACTION_CANCEL); @@ -1648,7 +1717,7 @@ class Service implements InjectionAwareInterface public function uncancelFromOrder(Order $order): bool { $orderId = $this->orderId($order); - $this->di['events_manager']->fire(['event' => 'onBeforeAdminOrderUncancel', 'params' => ['id' => $orderId]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminOrderUncancelEvent($orderId)); $this->_callOnService($order, Order::ACTION_UNCANCEL); @@ -1671,7 +1740,7 @@ class Service implements InjectionAwareInterface $this->saveStatusChange($order, 'Activated canceled order'); - $this->di['events_manager']->fire(['event' => 'onAfterAdminOrderUncancel', 'params' => ['id' => $orderId]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminOrderUncancelEvent($orderId)); $this->di['logger']->info('Uncanceled order #{order_id}', ['order_id' => $orderId]); @@ -1717,7 +1786,7 @@ class Service implements InjectionAwareInterface public function deleteFromOrder(Order $order, bool $forceDelete = false): bool { $orderId = $this->orderId($order); - $this->di['events_manager']->fire(['event' => 'onBeforeAdminOrderDelete', 'params' => ['id' => $orderId]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminOrderDeleteEvent($orderId)); $orderStatus = $order->getStatus(); if ($orderStatus == Order::STATUS_PENDING_SETUP) { @@ -1740,7 +1809,7 @@ class Service implements InjectionAwareInterface $this->getOrderMetaRepository()->deleteByOrderId($orderId); $this->rmOrder($order); - $this->di['events_manager']->fire(['event' => 'onAfterAdminOrderDelete', 'params' => ['id' => $orderId]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminOrderDeleteEvent($orderId)); $this->di['logger']->info('Deleted order #{order_id}', ['order_id' => $orderId]); return true; @@ -1753,7 +1822,7 @@ class Service implements InjectionAwareInterface public function batchSendSuspensionWarnings(): bool { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminBatchSendSuspensionWarnings']); + $this->di['event_dispatcher']->dispatch(new BeforeAdminBatchSendSuspensionWarningsEvent()); $emailService = $this->di['mod_service']('email'); foreach ($this->getOrderRepository()->getDueSuspensionWarnings() as $candidate) { @@ -1779,7 +1848,7 @@ class Service implements InjectionAwareInterface } } - $this->di['events_manager']->fire(['event' => 'onAfterAdminBatchSendSuspensionWarnings']); + $this->di['event_dispatcher']->dispatch(new AfterAdminBatchSendSuspensionWarningsEvent()); $this->di['logger']->info('Executed action to send order suspension warnings'); return true; @@ -1830,7 +1899,7 @@ class Service implements InjectionAwareInterface public function batchSuspendExpired(): bool { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminBatchSuspendOrders']); + $this->di['event_dispatcher']->dispatch(new BeforeAdminBatchSuspendOrdersEvent()); $mod = $this->di['mod']('order'); $c = $mod->getConfig(); @@ -1848,7 +1917,7 @@ class Service implements InjectionAwareInterface } } - $this->di['events_manager']->fire(['event' => 'onAfterAdminBatchSuspendOrders']); + $this->di['event_dispatcher']->dispatch(new AfterAdminBatchSuspendOrdersEvent()); $this->di['logger']->info('Executed action to suspend expired orders'); @@ -1857,7 +1926,7 @@ class Service implements InjectionAwareInterface public function batchCancelSuspended(): bool { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminBatchCancelSuspendedOrders']); + $this->di['event_dispatcher']->dispatch(new BeforeAdminBatchCancelSuspendedOrdersEvent()); $mod = $this->di['mod']('order'); $config = $mod->getConfig(); @@ -1895,7 +1964,7 @@ class Service implements InjectionAwareInterface } } - $this->di['events_manager']->fire(['event' => 'onAfterAdminBatchCancelSuspendedOrders']); + $this->di['event_dispatcher']->dispatch(new AfterAdminBatchCancelSuspendedOrdersEvent()); $this->di['logger']->info('Executed action to cancel suspended orders'); @@ -1904,7 +1973,7 @@ class Service implements InjectionAwareInterface public function batchCancelUnpaid(): bool { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminBatchCancelUnpaidOrders']); + $this->di['event_dispatcher']->dispatch(new BeforeAdminBatchCancelUnpaidOrdersEvent()); $mod = $this->di['mod']('order'); $config = $mod->getConfig(); @@ -1985,7 +2054,7 @@ class Service implements InjectionAwareInterface } } - $this->di['events_manager']->fire(['event' => 'onAfterAdminBatchCancelUnpaidOrders']); + $this->di['event_dispatcher']->dispatch(new AfterAdminBatchCancelUnpaidOrdersEvent()); $this->di['logger']->info('Executed action to remove stale unpaid orders'); diff --git a/src/modules/Order/tests/Unit/ServiceTest.php b/src/modules/Order/tests/Unit/ServiceTest.php index cd435c4da..2bf9fd33f 100644 --- a/src/modules/Order/tests/Unit/ServiceTest.php +++ b/src/modules/Order/tests/Unit/ServiceTest.php @@ -45,6 +45,19 @@ function orderServiceCreateInvoiceModel(int $id): Invoice return $invoice; } +final class OrderServiceTestEventRecorder +{ + /** @var list */ + public array $events = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + + return $event; + } +} + test('counter returns status counts', function (): void { $service = new Service(); @@ -165,11 +178,8 @@ test('batch client serialization excludes admin-only fields on the real path', f } }); -test('onAfterAdminOrderActivate fires template', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params); +test('typed order activate listener fires template', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderActivateEvent(1); $emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class); $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once()->andReturn(true); @@ -199,17 +209,13 @@ test('onAfterAdminOrderActivate fires template', function (): void { } }); - $eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di); $serviceMock->setDi($di); - $serviceMock->onAfterAdminOrderActivate($eventMock); + $serviceMock->sendOrderActivationEmail($eventMock); }); -test('onAfterAdminOrderActivate logs exceptions', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params); +test('typed order activate listener logs exceptions', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderActivateEvent(1); $emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class); $emailServiceMock->shouldReceive('sendTemplate') @@ -241,17 +247,13 @@ test('onAfterAdminOrderActivate logs exceptions', function (): void { } }); - $eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di); $serviceMock->setDi($di); - $serviceMock->onAfterAdminOrderActivate($eventMock); + $serviceMock->sendOrderActivationEmail($eventMock); }); -test('onAfterAdminOrderRenew fires template', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params); +test('typed order renew listener fires template', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderRenewEvent(1); $emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class); $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once()->andReturn(true); @@ -282,16 +284,12 @@ test('onAfterAdminOrderRenew fires template', function (): void { }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di); - $serviceMock->onAfterAdminOrderRenew($eventMock); + $serviceMock->sendOrderRenewalEmail($eventMock); }); -test('onAfterAdminOrderRenew fires template without an admin session', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->once()->andReturn($params); +test('typed order renew listener fires template without an admin session', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderRenewEvent(1); $order = createEntity(Order::class, ['id' => 1]); $orderArr = [ @@ -325,16 +323,12 @@ test('onAfterAdminOrderRenew fires template without an admin session', function }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->once()->andReturn($di); - $serviceMock->onAfterAdminOrderRenew($eventMock); + $serviceMock->sendOrderRenewalEmail($eventMock); }); -test('onAfterAdminOrderRenew logs exceptions', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params); +test('typed order renew listener logs exceptions', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderRenewEvent(1); $emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class); $emailServiceMock->shouldReceive('sendTemplate') @@ -367,16 +361,12 @@ test('onAfterAdminOrderRenew logs exceptions', function (): void { }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di); - $serviceMock->onAfterAdminOrderRenew($eventMock); + $serviceMock->sendOrderRenewalEmail($eventMock); }); -test('onAfterAdminOrderSuspend fires template', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params); +test('typed order suspend listener fires template', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderSuspendEvent(1); $emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class); $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once()->andReturn(true); @@ -407,16 +397,12 @@ test('onAfterAdminOrderSuspend fires template', function (): void { }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di); - $serviceMock->onAfterAdminOrderSuspend($eventMock); + $serviceMock->sendOrderSuspensionEmail($eventMock); }); -test('onAfterAdminOrderSuspend fires template without an admin session', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->once()->andReturn($params); +test('typed order suspend listener fires template without an admin session', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderSuspendEvent(1); $order = createEntity(Order::class, ['id' => 1]); $orderArr = [ @@ -450,16 +436,12 @@ test('onAfterAdminOrderSuspend fires template without an admin session', functio }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->once()->andReturn($di); - $serviceMock->onAfterAdminOrderSuspend($eventMock); + $serviceMock->sendOrderSuspensionEmail($eventMock); }); -test('onAfterAdminOrderSuspend logs exceptions', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params); +test('typed order suspend listener logs exceptions', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderSuspendEvent(1); $emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class); $emailServiceMock->shouldReceive('sendTemplate') @@ -492,16 +474,12 @@ test('onAfterAdminOrderSuspend logs exceptions', function (): void { }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di); - $serviceMock->onAfterAdminOrderSuspend($eventMock); + $serviceMock->sendOrderSuspensionEmail($eventMock); }); -test('onAfterAdminOrderUnsuspend fires template', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params); +test('typed order unsuspend listener fires template', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderUnsuspendEvent(1); $emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class); $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once()->andReturn(true); @@ -532,16 +510,12 @@ test('onAfterAdminOrderUnsuspend fires template', function (): void { }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di); - $serviceMock->onAfterAdminOrderUnsuspend($eventMock); + $serviceMock->sendOrderUnsuspensionEmail($eventMock); }); -test('onAfterAdminOrderUnsuspend fires template without an admin session', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->once()->andReturn($params); +test('typed order unsuspend listener fires template without an admin session', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderUnsuspendEvent(1); $order = createEntity(Order::class, ['id' => 1]); $orderArr = [ @@ -575,16 +549,12 @@ test('onAfterAdminOrderUnsuspend fires template without an admin session', funct }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->once()->andReturn($di); - $serviceMock->onAfterAdminOrderUnsuspend($eventMock); + $serviceMock->sendOrderUnsuspensionEmail($eventMock); }); -test('onAfterAdminOrderUnsuspend logs exceptions', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params); +test('typed order unsuspend listener logs exceptions', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderUnsuspendEvent(1); $emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class); $emailServiceMock->shouldReceive('sendTemplate') @@ -617,16 +587,12 @@ test('onAfterAdminOrderUnsuspend logs exceptions', function (): void { }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di); - $serviceMock->onAfterAdminOrderUnsuspend($eventMock); + $serviceMock->sendOrderUnsuspensionEmail($eventMock); }); -test('onAfterAdminOrderCancel fires template', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params); +test('typed order cancel listener fires template', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderCancelEvent(1); $emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class); $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once()->andReturn(true); @@ -656,16 +622,12 @@ test('onAfterAdminOrderCancel fires template', function (): void { }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di); - $serviceMock->onAfterAdminOrderCancel($eventMock); + $serviceMock->sendOrderCancellationEmail($eventMock); }); -test('onAfterAdminOrderCancel fires template without an admin session', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->once()->andReturn($params); +test('typed order cancel listener fires template without an admin session', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderCancelEvent(1); $order = createEntity(Order::class, ['id' => 1]); $orderArr = [ @@ -697,16 +659,12 @@ test('onAfterAdminOrderCancel fires template without an admin session', function }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->once()->andReturn($di); - $serviceMock->onAfterAdminOrderCancel($eventMock); + $serviceMock->sendOrderCancellationEmail($eventMock); }); -test('onAfterAdminOrderCancel logs exceptions', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params); +test('typed order cancel listener logs exceptions', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderCancelEvent(1); $emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class); $emailServiceMock->shouldReceive('sendTemplate') @@ -738,16 +696,12 @@ test('onAfterAdminOrderCancel logs exceptions', function (): void { }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di); - $serviceMock->onAfterAdminOrderCancel($eventMock); + $serviceMock->sendOrderCancellationEmail($eventMock); }); -test('onAfterAdminOrderUncancel fires template', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params); +test('typed order uncancel listener fires template', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderUncancelEvent(1); $emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class); $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once()->andReturn(true); @@ -778,16 +732,12 @@ test('onAfterAdminOrderUncancel fires template', function (): void { }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di); - $serviceMock->onAfterAdminOrderUncancel($eventMock); + $serviceMock->sendOrderUncancelEmail($eventMock); }); -test('onAfterAdminOrderUncancel fires template without an admin session', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->once()->andReturn($params); +test('typed order uncancel listener fires template without an admin session', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderUncancelEvent(1); $order = createEntity(Order::class, ['id' => 1]); $orderArr = [ @@ -821,16 +771,12 @@ test('onAfterAdminOrderUncancel fires template without an admin session', functi }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->once()->andReturn($di); - $serviceMock->onAfterAdminOrderUncancel($eventMock); + $serviceMock->sendOrderUncancelEmail($eventMock); }); -test('onAfterAdminOrderUncancel logs exceptions', function (): void { - $params = ['id' => 1]; - - $eventMock = Mockery::mock(Box_Event::class); - $eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params); +test('typed order uncancel listener logs exceptions', function (): void { + $eventMock = new Box\Mod\Order\Event\AfterAdminOrderUncancelEvent(1); $emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class); $emailServiceMock->shouldReceive('sendTemplate') @@ -863,9 +809,8 @@ test('onAfterAdminOrderUncancel logs exceptions', function (): void { }); $serviceMock->setDi($di); - $eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di); - $serviceMock->onAfterAdminOrderUncancel($eventMock); + $serviceMock->sendOrderUncancelEmail($eventMock); }); test('getOrderService returns core service', function (): void { @@ -1872,8 +1817,7 @@ test('createOrder throws when out of stock', function (): void { ->with($modelProduct, Mockery::any()) ->andReturn(false); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $di = container(); $di['mod_service'] = $di->protect(function ($serviceName) use ($currencyServiceMock, $cartServiceMock) { @@ -1884,13 +1828,26 @@ test('createOrder throws when out of stock', function (): void { return $cartServiceMock; } }); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $svc = new Service(); $svc->setDi($di); - expect(fn () => $svc->createOrder($modelClient, $modelProduct, [])) + $input = [ + 'quantity' => 2, + 'config' => ['password' => 'provisioning-secret'], + 'transactionId' => 'payment-secret', + 'gateway_id' => 7, + 'promo_code' => 'private-code', + 'meta' => ['access_token' => 'private-token'], + ]; + + expect(fn () => $svc->createOrder($modelClient, $modelProduct, $input)) ->toThrow(FOSSBilling\Exception::class, 'Product 1 is out of stock.'); + + expect($eventDispatcher->events)->toHaveCount(1) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminOrderCreateEvent::class) + ->and($eventDispatcher->events[0]->input)->toBe(['quantity' => 2]); }); test('createOrder throws when group id missing for addon', function (): void { @@ -1913,8 +1870,7 @@ test('createOrder throws when group id missing for addon', function (): void { ->with($modelProduct, Mockery::any()) ->andReturn(true); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $di = container(); $di['mod_service'] = $di->protect(function ($serviceName) use ($currencyServiceMock, $cartServiceMock) { @@ -1925,7 +1881,7 @@ test('createOrder throws when group id missing for addon', function (): void { return $cartServiceMock; } }); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $svc = new Service(); $svc->setDi($di); @@ -1953,8 +1909,7 @@ test('createOrder throws when parent order not found', function (): void { ->with($modelProduct, Mockery::any()) ->andReturn(true); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $di = container(); $di['mod_service'] = $di->protect(function ($serviceName) use ($currencyServiceMock, $cartServiceMock) { @@ -1965,7 +1920,7 @@ test('createOrder throws when parent order not found', function (): void { return $cartServiceMock; } }); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldAllowMockingProtectedMethods(); @@ -1999,8 +1954,7 @@ test('createOrder creates order', function (): void { ->with($modelProduct, Mockery::any()) ->andReturn(true); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $productServiceMock = Mockery::mock(Box\Mod\Servicecustom\Service::class); $pricingServiceMock = Mockery::mock(Box\Mod\Product\Service::class); @@ -2064,7 +2018,7 @@ test('createOrder creates order', function (): void { return $productServiceMock; } }); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['em'] = $emMock; $di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock); $di['logger'] = new FOSSBilling\Logger(); @@ -2074,7 +2028,12 @@ test('createOrder creates order', function (): void { $result = $svc->createOrder($modelClient, $modelProduct, ['period' => '1Y', 'price' => '10', 'notes' => 'test']); - expect($result)->toEqual($newId); + expect($result)->toEqual($newId) + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminOrderCreateEvent::class) + ->and($eventDispatcher->events[0]->productId)->toBe(1) + ->and($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminOrderCreateEvent::class) + ->and($eventDispatcher->events[1]->orderId)->toBe($newId); }); test('createOrder sets form id from product', function (): void { @@ -2097,8 +2056,7 @@ test('createOrder sets form id from product', function (): void { ->with($modelProduct, Mockery::any()) ->andReturn(true); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $productServiceMock = Mockery::mock(Box\Mod\Servicecustom\Service::class); $pricingServiceMock = Mockery::mock(Box\Mod\Product\Service::class); @@ -2162,7 +2120,7 @@ test('createOrder sets form id from product', function (): void { return $productServiceMock; } }); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['em'] = $emMock; $di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock); $di['logger'] = new FOSSBilling\Logger(); @@ -2194,8 +2152,7 @@ test('createOrder returns success when invoice follow up fails', function (): vo ->with($modelProduct, Mockery::any()) ->andReturn(true); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $productServiceMock = Mockery::mock(Box\Mod\Servicecustom\Service::class); $pricingServiceMock = Mockery::mock(Box\Mod\Product\Service::class); @@ -2287,7 +2244,7 @@ test('createOrder returns success when invoice follow up fails', function (): vo return $productServiceMock; } }); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['em'] = $emMock; $di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock); $di['logger'] = new FOSSBilling\Logger(); @@ -2328,8 +2285,7 @@ test('createOrder uses product pricing service for domain orders', function (): ->with($modelProduct, Mockery::any()) ->andReturn(true); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $domainServiceMock = Mockery::mock(Box\Mod\Servicedomain\Service::class)->shouldIgnoreMissing(); @@ -2401,7 +2357,7 @@ test('createOrder uses product pricing service for domain orders', function (): return $domainServiceMock; } }); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['em'] = $emMock; $di['logger'] = new FOSSBilling\Logger(); @@ -2624,12 +2580,11 @@ test('activateOrder activates pending order', function (): void { $emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock); $emMock->shouldIgnoreMissing(); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new FOSSBilling\Logger(); $serviceMock = Mockery::mock(Service::class)->makePartial(); @@ -2641,7 +2596,12 @@ test('activateOrder activates pending order', function (): void { $result = $serviceMock->activateOrder($clientOrderModel); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminOrderActivateEvent::class) + ->and($eventDispatcher->events[0]->orderId)->toBe(1) + ->and($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminOrderActivateEvent::class) + ->and($eventDispatcher->events[1]->orderId)->toBe(1); }); test('activateOrder is a no-op when order was already activated by a stale reference', function (): void { @@ -2698,12 +2658,11 @@ test('activateOrder force re-activates an already active order', function (): vo $emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock); $emMock->shouldIgnoreMissing(); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new FOSSBilling\Logger(); $serviceMock = Mockery::mock(Service::class)->makePartial(); @@ -2715,7 +2674,10 @@ test('activateOrder force re-activates an already active order', function (): vo $result = $serviceMock->activateOrder($activeOrderModel, ['force' => true]); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminOrderActivateEvent::class) + ->and($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminOrderActivateEvent::class); }); test('activateOrderAddons activates addons', function (): void { @@ -2733,17 +2695,19 @@ test('activateOrderAddons activates addons', function (): void { ->atLeast()->once() ->andReturn([$clientOrderModel]); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $serviceMock->setDi($di); $result = $serviceMock->activateOrderAddons($clientOrderModel); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminOrderActivateEvent::class) + ->and($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminOrderActivateEvent::class); }); test('getOrderAddonsList returns addons', function (): void { @@ -2819,11 +2783,10 @@ test('stockSale throws when quantity would go negative', function (): void { test('updateOrder updates fields', function (): void { $clientOrderModel = createEntity(Order::class); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['em'] = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class)->shouldIgnoreMissing(); $di['logger'] = new FOSSBilling\Logger(); @@ -2839,7 +2802,8 @@ test('updateOrder updates fields', function (): void { 'notes' => 'Empty note', 'reason' => 'non', 'suspension_grace_days' => 3, - 'meta' => [], + 'meta' => ['api_token' => 'private-token'], + 'config' => ['password' => 'provisioning-secret'], ]; $serviceMock = Mockery::mock(Service::class)->makePartial(); @@ -2852,19 +2816,24 @@ test('updateOrder updates fields', function (): void { $result = $serviceMock->updateOrder($clientOrderModel, $data); expect($result)->toBeTrue() - ->and($clientOrderModel->getSuspensionGraceDays())->toBe(3); + ->and($clientOrderModel->getSuspensionGraceDays())->toBe(3) + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminOrderUpdateEvent::class) + ->and($eventDispatcher->events[0]->orderId)->toBe((int) $clientOrderModel->getId()) + ->and($eventDispatcher->events[0]->input)->not->toHaveKey('meta') + ->and($eventDispatcher->events[0]->input)->not->toHaveKey('config') + ->and($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminOrderUpdateEvent::class); }); test('renewOrder renews order', function (): void { - $clientOrderModel = createEntity(Order::class); + $clientOrderModel = createEntity(Order::class, ['id' => 9]); $clientOrderModel->group_master = 1; $clientOrderModel->status = Order::STATUS_PENDING_SETUP; - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new FOSSBilling\Logger(); $serviceMock = Mockery::mock(Service::class)->makePartial(); @@ -2876,7 +2845,12 @@ test('renewOrder renews order', function (): void { $result = $serviceMock->renewOrder($clientOrderModel); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminOrderRenewEvent::class) + ->and($eventDispatcher->events[0]->orderId)->toBe(9) + ->and($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminOrderRenewEvent::class) + ->and($eventDispatcher->events[1]->orderId)->toBe(9); }); test('renewFromOrder extends expiration', function (): void { @@ -2973,11 +2947,10 @@ test('suspendFromOrder throws for non-active order', function (): void { $clientOrderModel = createEntity(Order::class); $clientOrderModel->status = Order::STATUS_SUSPENDED; - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $svc = new Service(); $svc->setDi($di); @@ -2987,14 +2960,13 @@ test('suspendFromOrder throws for non-active order', function (): void { }); test('suspendFromOrder suspends active order', function (): void { - $clientOrderModel = createEntity(Order::class); + $clientOrderModel = createEntity(Order::class, ['id' => 11]); $clientOrderModel->status = Order::STATUS_ACTIVE; - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new FOSSBilling\Logger(); $serviceMock = Mockery::mock(Service::class)->makePartial(); @@ -3006,7 +2978,12 @@ test('suspendFromOrder suspends active order', function (): void { $result = $serviceMock->suspendFromOrder($clientOrderModel); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminOrderSuspendEvent::class) + ->and($eventDispatcher->events[0]->orderId)->toBe(11) + ->and($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminOrderSuspendEvent::class) + ->and($eventDispatcher->events[1]->orderId)->toBe(11); }); test('cancelFromOrder cancels linked subscriptions', function (): void { @@ -3047,6 +3024,8 @@ test('cancelFromOrder cancels linked subscriptions', function (): void { $di = container(); $di['em'] = $emMock; $di['dbal'] = $connectionMock; + $eventDispatcher = new OrderServiceTestEventRecorder(); + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new FOSSBilling\Logger(); $di['mod_service'] = $di->protect(function (string $module, string $service = '') use ($productService, $subscriptionService) { if ($module === 'Invoice' && $service === 'Subscription') { @@ -3066,9 +3045,56 @@ test('cancelFromOrder cancels linked subscriptions', function (): void { $serviceMock->shouldReceive('saveStatusChange')->once(); $serviceMock->setDi($di); - expect($serviceMock->cancelFromOrder($clientOrderModel, skipEvent: true))->toBeTrue() + expect($serviceMock->cancelFromOrder($clientOrderModel))->toBeTrue() ->and($clientOrderModel->getStatus())->toBe(Order::STATUS_CANCELED) - ->and($calls)->toBe(['service', 'subscriptions']); + ->and($calls)->toBe(['service', 'subscriptions']) + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminOrderCancelEvent::class) + ->and($eventDispatcher->events[0]->orderId)->toBe(10) + ->and($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminOrderCancelEvent::class) + ->and($eventDispatcher->events[1]->orderId)->toBe(10); +}); + +test('gateway finalized cancellation dispatches both lifecycle events', function (): void { + $order = createEntity(Order::class, [ + 'id' => 10, + 'status' => Order::STATUS_ACTIVE, + ]); + + $connection = Mockery::mock(Doctrine\DBAL\Connection::class); + $connection->shouldReceive('executeStatement') + ->once() + ->with( + 'DELETE FROM client_order_meta WHERE client_order_id = :order_id AND name = :name', + ['order_id' => 10, 'name' => Service::META_CANCEL_AT_PERIOD_END], + ); + + $productService = Mockery::mock(Box\Mod\Product\Service::class); + $productService->shouldReceive('releaseReservedPromoRedemptionsForOrder') + ->once()->with($order, 'order_canceled'); + $productService->shouldReceive('releaseReservedStockForOrder') + ->once()->with($order, 'order_canceled'); + + $di = container(); + $di['em'] = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class)->shouldIgnoreMissing(); + $di['dbal'] = $connection; + $di['mod_service'] = $di->protect(static fn (string $module): object => $productService); + $events = new OrderServiceTestEventRecorder(); + $di['event_dispatcher'] = $events; + + $service = Mockery::mock(Service::class)->makePartial(); + $service->shouldAllowMockingProtectedMethods(); + $service->shouldReceive('_callOnService')->once(); + $service->shouldReceive('saveStatusChange')->once()->with($order, 'Canceled order for Subscription ended'); + $service->setDi($di); + + expect($service->finalizeCancellationFromGateway($order, 'Subscription ended'))->toBeTrue() + ->and($order->getStatus())->toBe(Order::STATUS_CANCELED) + ->and($events->events)->toHaveCount(2) + ->and($events->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminOrderCancelEvent::class) + ->and($events->events[0]->orderId)->toBe(10) + ->and($events->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminOrderCancelEvent::class) + ->and($events->events[1]->orderId)->toBe(10); }); test('scheduleCancellationFromOrder keeps the service active', function (): void { @@ -3694,11 +3720,10 @@ test('createOrder rejects invalid price and quantity', function (array $data, st test('updateOrder rejects a negative price', function (): void { $order = createEntity(Order::class); - $events = Mockery::mock(Box_EventManager::class); - $events->shouldReceive('fire')->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $di = container(); - $di['events_manager'] = $events; + $di['event_dispatcher'] = $eventDispatcher; $service = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); $service->shouldReceive('updatePeriod')->once(); @@ -3727,8 +3752,7 @@ test('createOrder generates an invoice for a zero-price order with issue-invoice ->with($modelProduct, Mockery::any()) ->andReturn(true); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $productServiceMock = Mockery::mock(Box\Mod\Servicecustom\Service::class); $pricingServiceMock = Mockery::mock(Box\Mod\Product\Service::class); @@ -3807,7 +3831,7 @@ test('createOrder generates an invoice for a zero-price order with issue-invoice return $productServiceMock; } }); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['em'] = $emMock; $di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock); $di['logger'] = new FOSSBilling\Logger(); @@ -3844,8 +3868,7 @@ test('createOrder does not roll back when invoice generation fails for a negativ ->with($modelProduct, Mockery::any()) ->andReturn(true); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $productServiceMock = Mockery::mock(Box\Mod\Servicecustom\Service::class); $pricingServiceMock = Mockery::mock(Box\Mod\Product\Service::class); @@ -3921,7 +3944,7 @@ test('createOrder does not roll back when invoice generation fails for a negativ return $productServiceMock; } }); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['em'] = $emMock; $di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock); $di['logger'] = new FOSSBilling\Logger(); @@ -3957,9 +3980,10 @@ test('getExpiredOrders delegates grace-aware selection to the repository', funct test('batchCancelUnpaid returns false and does not query orders when auto removal is disabled', function (): void { $service = new Service(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $di = container(); - $di['events_manager'] = Mockery::mock('\Box_EventManager')->shouldIgnoreMissing(); + $di['event_dispatcher'] = $eventDispatcher; $di['mod'] = $di->protect(fn (string $name): object => new class { public function getConfig(): array { @@ -3969,7 +3993,59 @@ test('batchCancelUnpaid returns false and does not query orders when auto remova $service->setDi($di); - expect($service->batchCancelUnpaid())->toBeFalse(); + expect($service->batchCancelUnpaid())->toBeFalse() + ->and($eventDispatcher->events)->toHaveCount(1) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminBatchCancelUnpaidOrdersEvent::class); +}); + +test('batchSuspendExpired dispatches typed lifecycle events when there are no expired orders', function (): void { + $service = Mockery::mock(Service::class)->makePartial(); + $service->shouldReceive('getExpiredOrders')->once()->andReturn([]); + $eventDispatcher = new OrderServiceTestEventRecorder(); + + $di = container(); + $di['event_dispatcher'] = $eventDispatcher; + $di['logger'] = new FOSSBilling\Logger(); + $di['mod'] = $di->protect(fn (string $name): object => new class { + public function getConfig(): array + { + return []; + } + }); + $service->setDi($di); + + expect($service->batchSuspendExpired())->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminBatchSuspendOrdersEvent::class) + ->and($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminBatchSuspendOrdersEvent::class); +}); + +test('batchCancelSuspended dispatches typed lifecycle events when there are no eligible orders', function (): void { + $eventDispatcher = new OrderServiceTestEventRecorder(); + $connection = Mockery::mock(Doctrine\DBAL\Connection::class); + $connection->shouldReceive('fetchAllAssociative')->once()->andReturn([]); + + $em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class); + $em->shouldReceive('getConnection')->once()->andReturn($connection); + + $di = container(); + $di['event_dispatcher'] = $eventDispatcher; + $di['em'] = $em; + $di['logger'] = new FOSSBilling\Logger(); + $di['mod'] = $di->protect(fn (string $name): object => new class { + public function getConfig(): array + { + return ['batch_cancel_suspended' => '1', 'batch_cancel_suspended_after_days' => 1]; + } + }); + + $service = new Service(); + $service->setDi($di); + + expect($service->batchCancelSuspended())->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminBatchCancelSuspendedOrdersEvent::class) + ->and($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminBatchCancelSuspendedOrdersEvent::class); }); test('deleteFromOrder removes client_order_meta rows before removing the order', function (): void { @@ -3994,10 +4070,7 @@ test('deleteFromOrder removes client_order_meta rows before removing the order', $productService->shouldReceive('releaseReservedPromoRedemptionsForOrder')->once()->with($order, 'order_deleted'); $productService->shouldReceive('releaseReservedStockForOrder')->once()->with($order, 'order_deleted'); - $eventsManager = Mockery::mock('\Box_EventManager')->shouldIgnoreMissing(); - $di = container(); - $di['events_manager'] = $eventsManager; $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod_service'] = $di->protect(fn (string $name): object => $productService); @@ -4006,7 +4079,7 @@ test('deleteFromOrder removes client_order_meta rows before removing the order', expect($serviceMock->deleteFromOrder($order))->toBeTrue(); }); -test('batchCancelUnpaid removes each stale unpaid order and fires events', function (): void { +test('batchCancelUnpaid removes each stale unpaid order and dispatches typed events', function (): void { $serviceMock = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods(); $orderA = createEntity(Order::class, ['id' => 1, 'status' => Order::STATUS_PENDING_SETUP]); @@ -4022,13 +4095,11 @@ test('batchCancelUnpaid removes each stale unpaid order and fires events', funct $emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepository); $emMock->shouldReceive('refresh')->twice(); - $eventsManager = Mockery::mock('\Box_EventManager'); - $eventsManager->shouldReceive('fire')->once()->with(['event' => 'onBeforeAdminBatchCancelUnpaidOrders']); - $eventsManager->shouldReceive('fire')->once()->with(['event' => 'onAfterAdminBatchCancelUnpaidOrders']); + $eventDispatcher = new OrderServiceTestEventRecorder(); $di = container(); $di['em'] = $emMock; - $di['events_manager'] = $eventsManager; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod'] = $di->protect(fn (string $name): object => new class { public function getConfig(): array @@ -4042,7 +4113,10 @@ test('batchCancelUnpaid removes each stale unpaid order and fires events', funct $serviceMock->setDi($di); - expect($serviceMock->batchCancelUnpaid())->toBeTrue(); + expect($serviceMock->batchCancelUnpaid())->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminBatchCancelUnpaidOrdersEvent::class) + ->and($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminBatchCancelUnpaidOrdersEvent::class); }); test('batchCancelUnpaid falls back to the 7 day default when the configured value is blank', function (): void { @@ -4059,7 +4133,6 @@ test('batchCancelUnpaid falls back to the 7 day default when the configured valu $di = container(); $di['em'] = $emMock; - $di['events_manager'] = Mockery::mock('\Box_EventManager')->shouldIgnoreMissing(); $di['mod'] = $di->protect(fn (string $name): object => new class { public function getConfig(): array { @@ -4106,7 +4179,6 @@ test('batchCancelUnpaid removes the linked unpaid invoice before deleting the or $di = container(); $di['em'] = $emMock; - $di['events_manager'] = Mockery::mock('\Box_EventManager')->shouldIgnoreMissing(); $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod'] = $di->protect(fn (string $name): object => new class { public function getConfig(): array @@ -4152,7 +4224,6 @@ test('batchCancelUnpaid resolves a shared invoice once and still removes every s $di = container(); $di['em'] = $emMock; - $di['events_manager'] = Mockery::mock('\Box_EventManager')->shouldIgnoreMissing(); $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod'] = $di->protect(fn (string $name): object => new class { public function getConfig(): array @@ -4208,7 +4279,6 @@ test('batchCancelUnpaid does not delete a sibling order when removing the shared $di = container(); $di['em'] = $emMock; - $di['events_manager'] = Mockery::mock('\Box_EventManager')->shouldIgnoreMissing(); $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod'] = $di->protect(fn (string $name): object => new class { public function getConfig(): array @@ -4254,7 +4324,6 @@ test('batchCancelUnpaid leaves the order alone when its invoice was paid since s $di = container(); $di['em'] = $emMock; - $di['events_manager'] = Mockery::mock('\Box_EventManager')->shouldIgnoreMissing(); $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod'] = $di->protect(fn (string $name): object => new class { public function getConfig(): array @@ -4299,7 +4368,6 @@ test('batchCancelUnpaid removes the order without touching the invoice when it w $di = container(); $di['em'] = $emMock; - $di['events_manager'] = Mockery::mock('\Box_EventManager')->shouldIgnoreMissing(); $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod'] = $di->protect(fn (string $name): object => new class { public function getConfig(): array @@ -4333,7 +4401,6 @@ test('batchCancelUnpaid logs and continues when removing one stale order fails', $di = container(); $di['em'] = $emMock; - $di['events_manager'] = Mockery::mock('\Box_EventManager')->shouldIgnoreMissing(); $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod'] = $di->protect(fn (string $name): object => new class { public function getConfig(): array @@ -4376,7 +4443,6 @@ test('batchCancelUnpaid skips an order that changed status while the batch was r $di = container(); $di['em'] = $emMock; - $di['events_manager'] = Mockery::mock('\Box_EventManager')->shouldIgnoreMissing(); $di['logger'] = new Tests\Helpers\TestLogger(); $di['mod'] = $di->protect(fn (string $name): object => new class { public function getConfig(): array @@ -4429,21 +4495,25 @@ test('batchSendSuspensionWarnings claims and queues each warning once', function && $email['order']['suspension_at'] === '2026-08-01 12:00:00' ))->andReturn(true); - $events = Mockery::mock(Box_EventManager::class); - $events->shouldReceive('fire')->times(4); + $eventDispatcher = new OrderServiceTestEventRecorder(); $service = Mockery::mock(Service::class)->makePartial(); $service->shouldReceive('toApiArray')->once()->with($order, false)->andReturn(['id' => 8]); $di = container(); $di['em'] = $em; - $di['events_manager'] = $events; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new FOSSBilling\Logger(); $di['mod_service'] = $di->protect(fn (string $name): Box\Mod\Email\Service => $emailService); $service->setDi($di); expect($service->batchSendSuspensionWarnings())->toBeTrue() - ->and($service->batchSendSuspensionWarnings())->toBeTrue(); + ->and($service->batchSendSuspensionWarnings())->toBeTrue() + ->and($eventDispatcher->events)->toHaveCount(4) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminBatchSendSuspensionWarningsEvent::class) + ->and($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminBatchSendSuspensionWarningsEvent::class) + ->and($eventDispatcher->events[2])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminBatchSendSuspensionWarningsEvent::class) + ->and($eventDispatcher->events[3])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminBatchSendSuspensionWarningsEvent::class); }); test('batchSendSuspensionWarnings releases a failed claim so the warning can be retried', function (): void { @@ -4480,22 +4550,26 @@ test('batchSendSuspensionWarnings releases a failed claim so the warning can be return true; }); - $events = Mockery::mock(Box_EventManager::class); - $events->shouldReceive('fire')->times(4); + $eventDispatcher = new OrderServiceTestEventRecorder(); $service = Mockery::mock(Service::class)->makePartial(); $service->shouldReceive('toApiArray')->twice()->with($order, false)->andReturn(['id' => 8]); $di = container(); $di['em'] = $em; - $di['events_manager'] = $events; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new FOSSBilling\Logger(); $di['mod_service'] = $di->protect(fn (string $name): Box\Mod\Email\Service => $emailService); $service->setDi($di); expect($service->batchSendSuspensionWarnings())->toBeTrue() ->and($service->batchSendSuspensionWarnings())->toBeTrue() - ->and($attempts)->toBe(2); + ->and($attempts)->toBe(2) + ->and($eventDispatcher->events)->toHaveCount(4) + ->and($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminBatchSendSuspensionWarningsEvent::class) + ->and($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminBatchSendSuspensionWarningsEvent::class) + ->and($eventDispatcher->events[2])->toBeInstanceOf(Box\Mod\Order\Event\BeforeAdminBatchSendSuspensionWarningsEvent::class) + ->and($eventDispatcher->events[3])->toBeInstanceOf(Box\Mod\Order\Event\AfterAdminBatchSendSuspensionWarningsEvent::class); }); test('exportCSV strips config from numeric-array headers', function (): void { @@ -4567,8 +4641,7 @@ test('createOrder applies a promo code and records the discount', function (): v ->with($modelProduct, Mockery::any()) ->andReturn(true); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $eventDispatcher = new OrderServiceTestEventRecorder(); $promo = new Box\Mod\Product\Entity\Promo(); $promoReflection = new ReflectionProperty($promo, 'id'); @@ -4693,7 +4766,7 @@ test('createOrder applies a promo code and records the discount', function (): v return $productServiceMock; } }); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['em'] = $emMock; $di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock); $di['logger'] = new FOSSBilling\Logger(); diff --git a/src/modules/Profile/Event/AfterAdminApiKeyChangeEvent.php b/src/modules/Profile/Event/AfterAdminApiKeyChangeEvent.php new file mode 100644 index 000000000..655cf2385 --- /dev/null +++ b/src/modules/Profile/Event/AfterAdminApiKeyChangeEvent.php @@ -0,0 +1,21 @@ + $data */ + public function __construct( + public readonly int $adminId, + public readonly array $data, + ) { + } +} diff --git a/src/modules/Profile/Event/BeforeClientProfilePasswordChangeEvent.php b/src/modules/Profile/Event/BeforeClientProfilePasswordChangeEvent.php new file mode 100644 index 000000000..03033c3ca --- /dev/null +++ b/src/modules/Profile/Event/BeforeClientProfilePasswordChangeEvent.php @@ -0,0 +1,21 @@ + $data */ + public function __construct( + public readonly int $clientId, + public readonly array $data, + ) { + } +} diff --git a/src/modules/Profile/Service.php b/src/modules/Profile/Service.php index bd0c35195..a88e324c4 100644 --- a/src/modules/Profile/Service.php +++ b/src/modules/Profile/Service.php @@ -12,6 +12,16 @@ declare(strict_types=1); namespace Box\Mod\Profile; use Box\Mod\Client\Entity\Client; +use Box\Mod\Profile\Event\AfterAdminApiKeyChangeEvent; +use Box\Mod\Profile\Event\AfterAdminProfilePasswordChangeEvent; +use Box\Mod\Profile\Event\AfterAdminProfileUpdateEvent; +use Box\Mod\Profile\Event\AfterClientProfilePasswordChangeEvent; +use Box\Mod\Profile\Event\AfterClientProfileUpdateEvent; +use Box\Mod\Profile\Event\BeforeAdminApiKeyChangeEvent; +use Box\Mod\Profile\Event\BeforeAdminProfilePasswordChangeEvent; +use Box\Mod\Profile\Event\BeforeAdminProfileUpdateEvent; +use Box\Mod\Profile\Event\BeforeClientProfilePasswordChangeEvent; +use Box\Mod\Profile\Event\BeforeClientProfileUpdateEvent; use Box\Mod\Staff\Entity\Admin; use FOSSBilling\i18n; use FOSSBilling\InformationException; @@ -23,6 +33,16 @@ use Symfony\Component\Intl\Locales; class Service implements InjectionAwareInterface { private const string SESSION_ATTRIBUTES_PREFIX = '_sf2_attributes|'; + private const array SENSITIVE_PROFILE_EVENT_FIELDS = [ + 'password', + 'password_confirm', + 'password_confirmation', + 'current_password', + 'new_password', + 'confirm_password', + 'pass', + 'api_token', + ]; protected ?\Pimple\Container $di = null; @@ -46,16 +66,14 @@ class Service implements InjectionAwareInterface public function changeAdminPassword(Admin $admin, $new_password): bool { - $event_params = ['id' => $admin->getId()]; - $this->di['events_manager']->fire(['event' => 'onBeforeAdminStaffProfilePasswordChange', 'params' => $event_params]); + $adminId = (int) $admin->getId(); + $this->di['event_dispatcher']->dispatch(new BeforeAdminProfilePasswordChangeEvent($adminId)); $admin->setPass($this->di['password']->hashIt($new_password)); $this->di['em']->persist($admin); $this->di['em']->flush(); - $event_params = []; - $event_params['id'] = $admin->getId(); - $this->di['events_manager']->fire(['event' => 'onAfterAdminStaffProfilePasswordChange', 'params' => $event_params]); + $this->di['event_dispatcher']->dispatch(new AfterAdminProfilePasswordChangeEvent($adminId)); $this->di['logger']->info('Changed profile password'); @@ -64,9 +82,8 @@ class Service implements InjectionAwareInterface public function generateNewApiKey(Admin $admin): bool { - $event_params = []; - $event_params['id'] = $admin->getId(); - $this->di['events_manager']->fire(['event' => 'onBeforeAdminStaffApiKeyChange', 'params' => $event_params]); + $adminId = (int) $admin->getId(); + $this->di['event_dispatcher']->dispatch(new BeforeAdminApiKeyChangeEvent($adminId)); $admin->setApiToken($this->di['tools']->generatePassword(32)); $this->di['em']->persist($admin); @@ -74,7 +91,7 @@ class Service implements InjectionAwareInterface $this->invalidateSessions('admin', (int) $admin->getId()); - $this->di['events_manager']->fire(['event' => 'onAfterAdminStaffApiKeyChange', 'params' => $event_params]); + $this->di['event_dispatcher']->dispatch(new AfterAdminApiKeyChangeEvent($adminId)); $this->di['logger']->info('Generated new API key'); @@ -83,9 +100,8 @@ class Service implements InjectionAwareInterface public function updateAdmin(Admin $admin, array $data): bool { - $event_params = $data; - $event_params['id'] = $admin->getId(); - $this->di['events_manager']->fire(['event' => 'onBeforeAdminStaffProfileUpdate', 'params' => $event_params]); + $adminId = (int) $admin->getId(); + $this->di['event_dispatcher']->dispatch(new BeforeAdminProfileUpdateEvent($adminId, $this->profileEventData($data))); $admin->setEmail($data['email'] ?? $admin->getEmail()); $admin->setName($data['name'] ?? $admin->getName()); @@ -96,9 +112,7 @@ class Service implements InjectionAwareInterface $this->di['em']->persist($admin); $this->di['em']->flush(); - $event_params = []; - $event_params['id'] = $admin->getId(); - $this->di['events_manager']->fire(['event' => 'onAfterAdminStaffProfileUpdate', 'params' => $event_params]); + $this->di['event_dispatcher']->dispatch(new AfterAdminProfileUpdateEvent($adminId)); $this->di['logger']->info('Updated profile'); @@ -122,9 +136,8 @@ class Service implements InjectionAwareInterface public function updateClient(Client $client, array $data = []): bool { - $event_params = $data; - $event_params['id'] = $client->getId(); - $this->di['events_manager']->fire(['event' => 'onBeforeClientProfileUpdate', 'params' => $event_params]); + $clientId = (int) $client->getId(); + $this->di['event_dispatcher']->dispatch(new BeforeClientProfileUpdateEvent($clientId, $this->profileEventData($data))); $mod = $this->di['mod']('client'); $config = $mod->getConfig(); @@ -220,7 +233,7 @@ class Service implements InjectionAwareInterface $this->di['em']->persist($client); $this->di['em']->flush(); - $this->di['events_manager']->fire(['event' => 'onAfterClientProfileUpdate', 'params' => ['id' => $client->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterClientProfileUpdateEvent($clientId)); $this->di['logger']->info('Updated profile'); @@ -241,16 +254,30 @@ class Service implements InjectionAwareInterface return $client->getApiToken(); } + /** + * @param array $data + * + * @return array + */ + private function profileEventData(array $data): array + { + foreach (self::SENSITIVE_PROFILE_EVENT_FIELDS as $field) { + unset($data[$field]); + } + + return $data; + } + public function changeClientPassword(Client $client, $new_password): bool { - $event_params = ['id' => $client->getId()]; - $this->di['events_manager']->fire(['event' => 'onBeforeClientProfilePasswordChange', 'params' => $event_params]); + $clientId = (int) $client->getId(); + $this->di['event_dispatcher']->dispatch(new BeforeClientProfilePasswordChangeEvent($clientId)); $client->setPass($this->di['password']->hashIt($new_password)); $this->di['em']->persist($client); $this->di['em']->flush(); - $this->di['events_manager']->fire(['event' => 'onAfterClientProfilePasswordChange', 'params' => ['id' => $client->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterClientProfilePasswordChangeEvent($clientId)); $this->di['logger']->info('Changed profile password'); diff --git a/src/modules/Profile/tests/Unit/ServiceTest.php b/src/modules/Profile/tests/Unit/ServiceTest.php index e859b88dc..1b38e4dfc 100644 --- a/src/modules/Profile/tests/Unit/ServiceTest.php +++ b/src/modules/Profile/tests/Unit/ServiceTest.php @@ -9,11 +9,36 @@ */ declare(strict_types=1); + +use Box\Mod\Profile\Event\AfterAdminApiKeyChangeEvent; +use Box\Mod\Profile\Event\AfterAdminProfilePasswordChangeEvent; +use Box\Mod\Profile\Event\AfterAdminProfileUpdateEvent; +use Box\Mod\Profile\Event\AfterClientProfilePasswordChangeEvent; +use Box\Mod\Profile\Event\AfterClientProfileUpdateEvent; +use Box\Mod\Profile\Event\BeforeAdminApiKeyChangeEvent; +use Box\Mod\Profile\Event\BeforeAdminProfilePasswordChangeEvent; +use Box\Mod\Profile\Event\BeforeAdminProfileUpdateEvent; +use Box\Mod\Profile\Event\BeforeClientProfilePasswordChangeEvent; +use Box\Mod\Profile\Event\BeforeClientProfileUpdateEvent; use Box\Mod\Profile\Service; +use FOSSBilling\Events\Event; use function Tests\Helpers\container; use function Tests\Helpers\createEntity; +final class ProfileTestEventDispatcher +{ + /** @var list */ + public array $events = []; + + public function dispatch(Event $event): Event + { + $this->events[] = $event; + + return $event; + } +} + test('gets dependency injection container', function (): void { $service = new Service(); $di = container(); @@ -31,14 +56,11 @@ test('gets admin identity array', function (): void { }); test('updates admin', function (): void { - $emMock = Mockery::mock('\Box_EventManager'); - $emMock->shouldReceive('fire') - ->atLeast()->once() - ->andReturn(true); + $eventDispatcher = new ProfileTestEventDispatcher(); $di = container(); $di['logger'] = new Tests\Helpers\TestLogger(); - $di['events_manager'] = $emMock; + $di['event_dispatcher'] = $eventDispatcher; $model = createEntity(Box\Mod\Staff\Entity\Admin::class); @@ -46,23 +68,32 @@ test('updates admin', function (): void { 'signature' => 'new signature', 'email' => 'example@gmail.com', 'name' => 'Admin', + 'password' => 'secret-password', + 'api_token' => 'secret-token', ]; $service = new Service(); $service->setDi($di); $result = $service->updateAdmin($model, $data); expect($result)->toBeTrue(); + expect($eventDispatcher->events)->toHaveCount(2); + expect($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminProfileUpdateEvent::class); + expect($eventDispatcher->events[0]->adminId)->toBe((int) $model->getId()); + expect($eventDispatcher->events[0]->data)->toBe([ + 'signature' => 'new signature', + 'email' => 'example@gmail.com', + 'name' => 'Admin', + ]); + expect($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminProfileUpdateEvent::class); + expect($eventDispatcher->events[1]->adminId)->toBe((int) $model->getId()); }); test('generates new api key', function (): void { - $emMock = Mockery::mock('\Box_EventManager'); - $emMock->shouldReceive('fire') - ->atLeast()->once() - ->andReturn(true); + $eventDispatcher = new ProfileTestEventDispatcher(); $di = container(); $di['logger'] = new Tests\Helpers\TestLogger(); - $di['events_manager'] = $emMock; + $di['event_dispatcher'] = $eventDispatcher; $di['tools'] = new FOSSBilling\Tools(); $model = createEntity(Box\Mod\Staff\Entity\Admin::class); @@ -76,14 +107,16 @@ test('generates new api key', function (): void { $result = $service->generateNewApiKey($model); expect($result)->toBeTrue(); + expect($eventDispatcher->events)->toHaveCount(2); + expect($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminApiKeyChangeEvent::class); + expect($eventDispatcher->events[0]->adminId)->toBe((int) $model->getId()); + expect($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminApiKeyChangeEvent::class); + expect($eventDispatcher->events[1]->adminId)->toBe((int) $model->getId()); }); test('changes admin password', function (): void { $password = 'new_pass'; - $emMock = Mockery::mock('\Box_EventManager'); - $emMock->shouldReceive('fire') - ->atLeast()->once() - ->andReturn(true); + $eventDispatcher = new ProfileTestEventDispatcher(); $passwordMock = Mockery::mock(FOSSBilling\PasswordManager::class); $passwordMock->shouldReceive('hashIt') @@ -91,7 +124,7 @@ test('changes admin password', function (): void { $di = container(); $di['logger'] = new Tests\Helpers\TestLogger(); - $di['events_manager'] = $emMock; + $di['event_dispatcher'] = $eventDispatcher; $di['password'] = $passwordMock; $model = createEntity(Box\Mod\Staff\Entity\Admin::class); @@ -101,13 +134,15 @@ test('changes admin password', function (): void { $result = $service->changeAdminPassword($model, $password); expect($result)->toBeTrue(); + expect($eventDispatcher->events)->toHaveCount(2); + expect($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminProfilePasswordChangeEvent::class); + expect($eventDispatcher->events[0]->adminId)->toBe((int) $model->getId()); + expect($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminProfilePasswordChangeEvent::class); + expect($eventDispatcher->events[1]->adminId)->toBe((int) $model->getId()); }); test('updates client', function (): void { - $emMock = Mockery::mock('\Box_EventManager'); - $emMock->shouldReceive('fire') - ->atLeast()->once() - ->andReturn(true); + $eventDispatcher = new ProfileTestEventDispatcher(); $modMock = Mockery::mock(FOSSBilling\Module::class); $modMock->shouldReceive('getConfig') @@ -125,7 +160,7 @@ test('updates client', function (): void { $di = container(); $di['logger'] = new Tests\Helpers\TestLogger(); - $di['events_manager'] = $emMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(fn ($name): Mockery\MockInterface => $clientServiceMock); $di['mod'] = $di->protect(fn (): Mockery\MockInterface => $modMock); $di['tools'] = $toolsMock; @@ -173,6 +208,8 @@ test('updates client', function (): void { 'custom_18' => 'string', 'custom_19' => 'string', 'custom_20' => 'string', + 'password' => 'secret-password', + 'api_token' => 'secret-token', ]; $service = new Service(); @@ -180,13 +217,18 @@ test('updates client', function (): void { $result = $service->updateClient($model, $data); expect($result)->toBeTrue(); expect($model->getBillingEmail())->toBe('billing@example.com'); + expect($eventDispatcher->events)->toHaveCount(2); + expect($eventDispatcher->events[0])->toBeInstanceOf(BeforeClientProfileUpdateEvent::class); + expect($eventDispatcher->events[0]->clientId)->toBe((int) $model->getId()); + $expectedEventData = $data; + unset($expectedEventData['password'], $expectedEventData['api_token']); + expect($eventDispatcher->events[0]->data)->toBe($expectedEventData); + expect($eventDispatcher->events[1])->toBeInstanceOf(AfterClientProfileUpdateEvent::class); + expect($eventDispatcher->events[1]->clientId)->toBe((int) $model->getId()); }); test('throws exception when email change is not allowed', function (): void { - $emMock = Mockery::mock('\Box_EventManager'); - $emMock->shouldReceive('fire') - ->atLeast()->once() - ->andReturn(true); + $eventDispatcher = new ProfileTestEventDispatcher(); $modMock = Mockery::mock(FOSSBilling\Module::class); $modMock->shouldReceive('getConfig') @@ -202,7 +244,7 @@ test('throws exception when email change is not allowed', function (): void { $di = container(); $di['logger'] = new Tests\Helpers\TestLogger(); - $di['events_manager'] = $emMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(fn ($name): Mockery\MockInterface => $clientServiceMock); $di['mod'] = $di->protect(fn (): Mockery\MockInterface => $modMock); @@ -215,13 +257,12 @@ test('throws exception when email change is not allowed', function (): void { expect(fn (): bool => $service->updateClient($model, $data)) ->toThrow(FOSSBilling\Exception::class); + expect($eventDispatcher->events)->toHaveCount(1); + expect($eventDispatcher->events[0])->toBeInstanceOf(BeforeClientProfileUpdateEvent::class); }); test('throws exception when email already registered', function (): void { - $emMock = Mockery::mock('\Box_EventManager'); - $emMock->shouldReceive('fire') - ->atLeast()->once() - ->andReturn(true); + $eventDispatcher = new ProfileTestEventDispatcher(); $modMock = Mockery::mock(FOSSBilling\Module::class); $modMock->shouldReceive('getConfig') @@ -240,7 +281,7 @@ test('throws exception when email already registered', function (): void { $di = container(); $di['logger'] = new Tests\Helpers\TestLogger(); - $di['events_manager'] = $emMock; + $di['event_dispatcher'] = $eventDispatcher; $di['mod_service'] = $di->protect(fn ($name): Mockery\MockInterface => $clientServiceMock); $di['mod'] = $di->protect(fn (): Mockery\MockInterface => $modMock); $di['tools'] = $toolsMock; @@ -254,6 +295,8 @@ test('throws exception when email already registered', function (): void { expect(fn (): bool => $service->updateClient($model, $data)) ->toThrow(FOSSBilling\Exception::class); + expect($eventDispatcher->events)->toHaveCount(1); + expect($eventDispatcher->events[0])->toBeInstanceOf(BeforeClientProfileUpdateEvent::class); }); test('resets api key', function (): void { @@ -275,10 +318,7 @@ test('resets api key', function (): void { }); test('changes client password', function (): void { - $emMock = Mockery::mock('\Box_EventManager'); - $emMock->shouldReceive('fire') - ->atLeast()->once() - ->andReturn(true); + $eventDispatcher = new ProfileTestEventDispatcher(); $password = 'new password'; @@ -288,7 +328,7 @@ test('changes client password', function (): void { $di = container(); $di['logger'] = new Tests\Helpers\TestLogger(); - $di['events_manager'] = $emMock; + $di['event_dispatcher'] = $eventDispatcher; $di['password'] = $passwordMock; $model = createEntity(Box\Mod\Client\Entity\Client::class); @@ -297,6 +337,11 @@ test('changes client password', function (): void { $service->setDi($di); $result = $service->changeClientPassword($model, $password); expect($result)->toBeTrue(); + expect($eventDispatcher->events)->toHaveCount(2); + expect($eventDispatcher->events[0])->toBeInstanceOf(BeforeClientProfilePasswordChangeEvent::class); + expect($eventDispatcher->events[0]->clientId)->toBe((int) $model->getId()); + expect($eventDispatcher->events[1])->toBeInstanceOf(AfterClientProfilePasswordChangeEvent::class); + expect($eventDispatcher->events[1]->clientId)->toBe((int) $model->getId()); }); test('logs out client', function (): void { diff --git a/src/modules/Seo/Service.php b/src/modules/Seo/Service.php index dd9316974..534b8de7e 100644 --- a/src/modules/Seo/Service.php +++ b/src/modules/Seo/Service.php @@ -11,7 +11,9 @@ declare(strict_types=1); namespace Box\Mod\Seo; +use Box\Mod\Cron\Event\BeforeAdminCronRunEvent; use FOSSBilling\InjectionAwareInterface; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\Filesystem\Path; use Symfony\Component\Finder\Finder; @@ -140,20 +142,16 @@ class Service implements InjectionAwareInterface return $details; } - public static function onBeforeAdminCronRun(\Box_Event $event): bool + #[AsEventListener] + public function pingSitemapOnCron(BeforeAdminCronRunEvent $event): void { - $di = $event->getDi(); - $extensionService = $di['mod_service']('extension'); + $extensionService = $this->di['mod_service']('extension'); $config = $extensionService->getConfig('mod_seo'); try { - $seoService = $di['mod_service']('seo'); - $seoService->setDi($di); - $seoService->pingSitemap($config); + $this->pingSitemap($config); } catch (\Exception $e) { - $di['logger']->error($e->getMessage()); + $this->di['logger']->error($e->getMessage()); } - - return true; } } diff --git a/src/modules/Seo/tests/Unit/ServiceTest.php b/src/modules/Seo/tests/Unit/ServiceTest.php new file mode 100644 index 000000000..4b042ab64 --- /dev/null +++ b/src/modules/Seo/tests/Unit/ServiceTest.php @@ -0,0 +1,28 @@ + 'on']; + $extension = Mockery::mock(Box\Mod\Extension\Service::class); + $extension->shouldReceive('getConfig')->once()->with('mod_seo')->andReturn($settings); + + $service = Mockery::mock(Service::class)->makePartial(); + $service->shouldReceive('pingSitemap')->once()->with($settings)->andReturn(true); + + $di = container(); + $di['mod_service'] = $di->protect(static fn (string $module): object => $extension); + $service->setDi($di); + + $service->pingSitemapOnCron(new BeforeAdminCronRunEvent()); +}); diff --git a/src/modules/Servicedomain/Api/Client.php b/src/modules/Servicedomain/Api/Client.php index c626cb3c5..75acda8ca 100644 --- a/src/modules/Servicedomain/Api/Client.php +++ b/src/modules/Servicedomain/Api/Client.php @@ -13,6 +13,8 @@ namespace Box\Mod\Servicedomain\Api; use Box\Mod\Order\Entity\Order; use Box\Mod\Servicedomain\Entity\ServiceDomain; +use Box\Mod\Servicedomain\Event\AfterClientChangeNameserversEvent; +use Box\Mod\Servicedomain\Event\BeforeClientChangeNameserversEvent; /** * Domain service management. @@ -31,11 +33,30 @@ class Client extends \FOSSBilling\Api\AbstractApi { $s = $this->_getService($data); - $this->getDi()['events_manager']->fire(['event' => 'onBeforeClientChangeNameservers', 'params' => $data]); + $nameservers = []; + foreach (['ns1', 'ns2', 'ns3', 'ns4'] as $key) { + $nameservers[$key] = isset($data[$key]) && is_string($data[$key]) ? $data[$key] : null; + } + + $this->getDi()['event_dispatcher']->dispatch(new BeforeClientChangeNameserversEvent( + $s->getId(), + $s->getClientId(), + $nameservers['ns1'], + $nameservers['ns2'], + $nameservers['ns3'], + $nameservers['ns4'], + )); $this->getService()->updateNameservers($s, $data); - $this->getDi()['events_manager']->fire(['event' => 'onAfterClientChangeNameservers', 'params' => $data]); + $this->getDi()['event_dispatcher']->dispatch(new AfterClientChangeNameserversEvent( + $s->getId(), + $s->getClientId(), + $nameservers['ns1'], + $nameservers['ns2'], + $nameservers['ns3'], + $nameservers['ns4'], + )); return true; } diff --git a/src/modules/Servicedomain/Event/AfterClientChangeNameserversEvent.php b/src/modules/Servicedomain/Event/AfterClientChangeNameserversEvent.php new file mode 100644 index 000000000..31fd103ec --- /dev/null +++ b/src/modules/Servicedomain/Event/AfterClientChangeNameserversEvent.php @@ -0,0 +1,26 @@ +getDi(); - $domainService = $di['mod_service']('servicedomain'); - $domainService->batchSyncExpirationDates(); + $this->batchSyncExpirationDates(); } catch (\Exception $e) { - $di['logger']->error($e->getMessage()); + $this->di['logger']->error($e->getMessage()); } - - return true; } public function batchSyncExpirationDates(): bool diff --git a/src/modules/Servicedomain/tests/Unit/Api/ClientTest.php b/src/modules/Servicedomain/tests/Unit/Api/ClientTest.php index 5c611c079..9d13970ac 100644 --- a/src/modules/Servicedomain/tests/Unit/Api/ClientTest.php +++ b/src/modules/Servicedomain/tests/Unit/Api/ClientTest.php @@ -14,40 +14,114 @@ use Box\Mod\Order\Entity\Order; use Box\Mod\Order\Service as OrderService; use Box\Mod\Servicedomain\Api\Client; use Box\Mod\Servicedomain\Entity\ServiceDomain; +use Box\Mod\Servicedomain\Event\AfterClientChangeNameserversEvent; +use Box\Mod\Servicedomain\Event\BeforeClientChangeNameserversEvent; use Box\Mod\Servicedomain\Service; +use Symfony\Component\EventDispatcher\EventDispatcher; use function Tests\Helpers\container; use function Tests\Helpers\createEntity; +use function Tests\Helpers\setEntityId; test('updates nameservers', function (): void { - $clientApi = apiEndpoint(new Client()); - $api = apiEndpoint(new Client()); - $model = new ServiceDomain(); + $model = (new ServiceDomain())->setClientId(17); + setEntityId($model, 42); $clientApiMock = apiEndpoint(Mockery::mock(Client::class)->makePartial()->shouldAllowMockingProtectedMethods()); $clientApiMock->shouldReceive('_getService') ->atLeast()->once() ->andReturn($model); + $timeline = []; $serviceMock = Mockery::mock(Service::class); $serviceMock->shouldReceive('updateNameservers') - ->atLeast()->once() - ->andReturn(true); + ->once() + ->with($model, [ + 'ns1' => 'ns1.example.test', + 'ns2' => 'ns2.example.test', + 'ns3' => null, + 'ns4' => null, + 'token' => 'private-token', + 'config' => ['registrar_password' => 'secret'], + ]) + ->andReturnUsing(function () use (&$timeline): bool { + expect($timeline)->toBe(['before']); + $timeline[] = 'service'; + + return true; + }); $clientApiMock->setService($serviceMock); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire') - ->atLeast()->once(); + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener(BeforeClientChangeNameserversEvent::class, static function (BeforeClientChangeNameserversEvent $event) use (&$timeline): void { + expect(get_object_vars($event))->toBe([ + 'domainId' => 42, + 'clientId' => 17, + 'ns1' => 'ns1.example.test', + 'ns2' => 'ns2.example.test', + 'ns3' => null, + 'ns4' => null, + ]); + $timeline[] = 'before'; + }); + $eventDispatcher->addListener(AfterClientChangeNameserversEvent::class, static function (AfterClientChangeNameserversEvent $event) use (&$timeline): void { + expect(get_object_vars($event))->toBe([ + 'domainId' => 42, + 'clientId' => 17, + 'ns1' => 'ns1.example.test', + 'ns2' => 'ns2.example.test', + 'ns3' => null, + 'ns4' => null, + ]); + $timeline[] = 'after'; + }); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $clientApiMock->setDi($di); - $data = []; + $data = [ + 'ns1' => 'ns1.example.test', + 'ns2' => 'ns2.example.test', + 'ns3' => null, + 'ns4' => null, + 'token' => 'private-token', + 'config' => ['registrar_password' => 'secret'], + ]; $result = $clientApiMock->update_nameservers($data); expect($result)->toBeTrue(); + expect($timeline)->toBe(['before', 'service', 'after']); +}); + +test('does not dispatch after nameserver event when registrar update fails', function (): void { + $model = new ServiceDomain(); + $clientApiMock = apiEndpoint(Mockery::mock(Client::class)->makePartial()->shouldAllowMockingProtectedMethods()); + $clientApiMock->shouldReceive('_getService')->once()->andReturn($model); + + $serviceMock = Mockery::mock(Service::class); + $serviceMock->shouldReceive('updateNameservers') + ->once() + ->andThrow(new RuntimeException('Registrar update failed')); + $clientApiMock->setService($serviceMock); + + $timeline = []; + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener(BeforeClientChangeNameserversEvent::class, static function () use (&$timeline): void { + $timeline[] = 'before'; + }); + $eventDispatcher->addListener(AfterClientChangeNameserversEvent::class, static function () use (&$timeline): void { + $timeline[] = 'after'; + }); + + $di = container(); + $di['event_dispatcher'] = $eventDispatcher; + $clientApiMock->setDi($di); + + expect(fn () => $clientApiMock->update_nameservers(['ns1' => 'ns1.example.test', 'ns2' => 'ns2.example.test'])) + ->toThrow(RuntimeException::class, 'Registrar update failed'); + expect($timeline)->toBe(['before']); }); test('updates contacts', function (): void { diff --git a/src/modules/Servicedomain/tests/Unit/ServiceTest.php b/src/modules/Servicedomain/tests/Unit/ServiceTest.php index 36abfc083..da855e3f8 100644 --- a/src/modules/Servicedomain/tests/Unit/ServiceTest.php +++ b/src/modules/Servicedomain/tests/Unit/ServiceTest.php @@ -1345,23 +1345,17 @@ test('converts admin domain to api array without a registrar', function (): void expect($result['registrar'])->toBeNull(); }); -test('handles on before admin cron run event', function (): void { - $service = new Service(); - $di = container(); - $serviceMock = Mockery::mock(Service::class); +test('syncs domain expiration dates before admin cron through a typed event listener', function (): void { + $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldReceive('batchSyncExpirationDates') - ->atLeast()->once() + ->once() ->andReturn(true); - $di['mod_service'] = $di->protect(fn ($serviceName): Mockery\MockInterface => $serviceMock); + $dispatcher = new FOSSBilling\Events\EventDispatcher( + static fn (): array => ['servicedomain'], + static fn (string $module): object => $serviceMock, + ); - $boxEventMock = Mockery::mock('\Box_Event'); - $boxEventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - - $result = $service->onBeforeAdminCronRun($boxEventMock); - - expect($result)->toBeTrue(); + $dispatcher->dispatch(new Box\Mod\Cron\Event\BeforeAdminCronRunEvent()); }); test('batch syncs expiration dates', function (): void { diff --git a/src/modules/Servicelicense/Event/AfterServiceLicenseResetEvent.php b/src/modules/Servicelicense/Event/AfterServiceLicenseResetEvent.php new file mode 100644 index 000000000..61627e24d --- /dev/null +++ b/src/modules/Servicelicense/Event/AfterServiceLicenseResetEvent.php @@ -0,0 +1,22 @@ + $model->getId(), - 'ips' => $model->getIps(), - 'hosts' => $model->getHosts(), - 'paths' => $model->getPaths(), - 'versions' => $model->getVersions(), - 'client_id' => $model->getClientId(), - ]; - $this->di['events_manager']->fire(['event' => 'onBeforeServicelicenseReset', 'params' => $data]); + $this->di['event_dispatcher']->dispatch(new BeforeServiceLicenseResetEvent($model->getId(), $model->getClientId())); $model->setIps(json_encode([])); $model->setHosts(json_encode([])); @@ -214,12 +208,7 @@ class Service implements InjectionAwareInterface $this->di['em']->flush(); $this->di['logger']->info('Reset license {model_id} information', ['model_id' => $model->getId()]); - $data = [ - 'id' => $model->getId(), - 'client_id' => $model->getClientId(), - 'updated_at' => $model->getUpdatedAt()?->format('Y-m-d H:i:s'), - ]; - $this->di['events_manager']->fire(['event' => 'onAfterServicelicenseReset', 'params' => $data]); + $this->di['event_dispatcher']->dispatch(new AfterServiceLicenseResetEvent($model->getId(), $model->getClientId())); return true; } diff --git a/src/modules/Servicelicense/tests/Unit/ServiceTest.php b/src/modules/Servicelicense/tests/Unit/ServiceTest.php index 3f4ff1ed3..a5dad7d92 100644 --- a/src/modules/Servicelicense/tests/Unit/ServiceTest.php +++ b/src/modules/Servicelicense/tests/Unit/ServiceTest.php @@ -14,6 +14,8 @@ use Box\Mod\Order\Entity\Order; use Box\Mod\Order\Service as OrderService; use Box\Mod\Product\Entity\Product; use Box\Mod\Servicelicense\Entity\ServiceLicense; +use Box\Mod\Servicelicense\Event\AfterServiceLicenseResetEvent; +use Box\Mod\Servicelicense\Event\BeforeServiceLicenseResetEvent; use Box\Mod\Servicelicense\Repository\ServiceLicenseRepository; use Box\Mod\Servicelicense\Server; use Box\Mod\Servicelicense\Service; @@ -21,6 +23,7 @@ use Doctrine\ORM\EntityManagerInterface; use function Tests\Helpers\container; use function Tests\Helpers\createEntity; +use function Tests\Helpers\setEntityId; function serviceLicenseCreateProductEntity(string $config): Product { @@ -225,22 +228,39 @@ test('action delete', function (): void { test('reset', function (): void { $service = new Service(); - $serviceLicenseModel = new ServiceLicense(); + $serviceLicenseModel = (new ServiceLicense())->setClientId(17)->setIps('["192.0.2.4"]'); + setEntityId($serviceLicenseModel, 42); - $eventMock = Mockery::mock(Box_EventManager::class); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $timeline = []; + $flushed = false; + $eventDispatcher = new Symfony\Component\EventDispatcher\EventDispatcher(); + $eventDispatcher->addListener(BeforeServiceLicenseResetEvent::class, static function (BeforeServiceLicenseResetEvent $event) use (&$timeline, &$flushed, $serviceLicenseModel): void { + expect($flushed)->toBeFalse(); + expect($serviceLicenseModel->getIps())->toBe('["192.0.2.4"]'); + expect(get_object_vars($event))->toBe(['licenseId' => 42, 'clientId' => 17]); + $timeline[] = 'before'; + }); + $eventDispatcher->addListener(AfterServiceLicenseResetEvent::class, static function (AfterServiceLicenseResetEvent $event) use (&$timeline, &$flushed): void { + expect($flushed)->toBeTrue(); + expect(get_object_vars($event))->toBe(['licenseId' => 42, 'clientId' => 17]); + $timeline[] = 'after'; + }); $em = Mockery::mock(EntityManagerInterface::class); - $em->shouldReceive('flush')->atLeast()->once(); + $em->shouldReceive('flush')->once()->andReturnUsing(function () use (&$flushed): void { + $flushed = true; + }); $di = container(); $di['em'] = $em; $di['logger'] = new FOSSBilling\Logger(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $service->setDi($di); $result = $service->reset($serviceLicenseModel); expect($result)->toBeTrue(); + expect($serviceLicenseModel->getIps())->toBe('[]'); + expect($timeline)->toBe(['before', 'after']); }); test('is license active', function (): void { diff --git a/src/modules/Staff/Api/Guest.php b/src/modules/Staff/Api/Guest.php index 1c25b8cc3..b624778d4 100644 --- a/src/modules/Staff/Api/Guest.php +++ b/src/modules/Staff/Api/Guest.php @@ -17,6 +17,9 @@ namespace Box\Mod\Staff\Api; use Box\Mod\Staff\Entity\Admin; use Box\Mod\Staff\Entity\AdminPasswordReset; +use Box\Mod\Staff\Event\AfterStaffPasswordResetEvent; +use Box\Mod\Staff\Event\BeforeStaffPasswordResetConfirmationEvent; +use Box\Mod\Staff\Event\BeforeStaffPasswordResetRequestEvent; use FOSSBilling\Security\RandomizedTimeFloor; use FOSSBilling\Validation\Api\RequiredParams; @@ -68,7 +71,7 @@ class Guest extends \FOSSBilling\Api\AbstractApi if (isset($config['public']['reset_pw']) && $config['public']['reset_pw'] == '0') { throw new \FOSSBilling\InformationException('Password reset has been disabled'); } - $this->getDi()['events_manager']->fire(['event' => 'onBeforePasswordResetStaff']); + $this->getDi()['event_dispatcher']->dispatch(new BeforeStaffPasswordResetConfirmationEvent($this->getIp())); $required = [ 'code' => 'Code required', 'password' => 'Password required', @@ -115,7 +118,7 @@ class Guest extends \FOSSBilling\Api\AbstractApi $this->getDi()['logger']->withChannel('security')->info('Staff password reset completed for admin #{admin_id} from IP {ip}', ['admin_id' => $admin->getId(), 'ip' => $this->getIp()]); - $this->getDi()['events_manager']->fire(['event' => 'onAfterPasswordResetStaff', 'params' => ['id' => $admin->getId()]]); + $this->getDi()['event_dispatcher']->dispatch(new AfterStaffPasswordResetEvent((int) $admin->getId())); // send email $email = []; @@ -142,7 +145,7 @@ class Guest extends \FOSSBilling\Api\AbstractApi $startedAt = microtime(true); try { - $this->getDi()['events_manager']->fire(['event' => 'onBeforePasswordResetStaff']); + $this->getDi()['event_dispatcher']->dispatch(new BeforeStaffPasswordResetRequestEvent($this->getIp())); $data['email'] = $this->getDi()['tools']->validateAndSanitizeEmail($data['email']); $ipLimit = $this->getDi()['rate_limiter']->consume('staff_password_reset_ip', (string) $this->getIp()); diff --git a/src/modules/Staff/Controller/Admin.php b/src/modules/Staff/Controller/Admin.php index e635b0698..0e210aa4b 100644 --- a/src/modules/Staff/Controller/Admin.php +++ b/src/modules/Staff/Controller/Admin.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Box\Mod\Staff\Controller; use Box\Mod\Staff\Entity\AdminPasswordReset; +use Box\Mod\Staff\Event\BeforeStaffPasswordResetConfirmationEvent; use FOSSBilling\InjectionAwareInterface; use FOSSBilling\Security\RandomizedTimeFloor; use Symfony\Component\HttpFoundation\Response; @@ -120,7 +121,7 @@ class Admin implements InjectionAwareInterface } $data = []; - $this->di['events_manager']->fire(['event' => 'onBeforePasswordResetStaff']); + $this->di['event_dispatcher']->dispatch(new BeforeStaffPasswordResetConfirmationEvent($this->di['request']->getClientIp())); $mod = $this->di['mod']('staff'); $config = $mod->getConfig(); diff --git a/src/modules/Staff/Event/AdminLoginFailedEvent.php b/src/modules/Staff/Event/AdminLoginFailedEvent.php new file mode 100644 index 000000000..1abd281e7 --- /dev/null +++ b/src/modules/Staff/Event/AdminLoginFailedEvent.php @@ -0,0 +1,20 @@ +di['events_manager']->fire(['event' => 'onBeforeAdminLogin', 'params' => $event_params]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminLoginEvent($ip)); $model = $this->authorizeAdmin($email, $password); if (!$model instanceof Admin) { - $this->di['events_manager']->fire(['event' => 'onEventAdminLoginFailed', 'params' => $event_params]); + $this->di['event_dispatcher']->dispatch(new AdminLoginFailedEvent($ip)); throw new \FOSSBilling\InformationException('Check your login details', null, 403); } - // Event listeners (e.g. this login being recorded in the login history) are normally - // connected by the cron job's hook_batch_connect task. Before cron has run for the - // first time, no listeners are connected and the event fired below would silently do - // nothing, so an admin's very first logins would go unrecorded. Connect them now so - // that gap does not exist. batchConnect() returns false if another process was still - // rebuilding the set when it gave up waiting; retry once rather than firing the event - // below against a set we know is incomplete. If both attempts fail, log it and let the - // login proceed anyway - failing the login itself over this housekeeping step would - // turn a rare missed audit entry into every admin being locked out while it's stuck. - $hookService = $this->di['mod_service']('hook'); - if (!$hookService->hasConnectedListeners()) { - $connected = $hookService->batchConnect(); - if (!$connected) { - $connected = $hookService->batchConnect(); - } - if (!$connected) { - $this->di['logger']->warning('Could not connect event listeners after two attempts; this login (and other events) may not be recorded.'); - } - } - - $this->di['events_manager']->fire(['event' => 'onAfterAdminLogin', 'params' => ['id' => $model->getId(), 'ip' => $ip]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminLoginEvent((int) $model->getId(), $ip)); $result = [ 'id' => $model->getId(), @@ -265,13 +260,13 @@ class Service implements InjectionAwareInterface } } - public static function onAfterClientOrderCreate(\Box_Event $event): void + #[AsEventListener] + public function notifyStaffAfterClientOrderCreate(AfterClientOrderCreateEvent $event): void { - $di = $event->getDi(); - $params = $event->getParameters(); + $di = $this->di ?? throw new \LogicException('Staff service must be initialized before handling events.'); try { - $orderModel = $di['em']->getRepository(\Box\Mod\Order\Entity\Order::class)->find($params['id']); + $orderModel = $di['em']->getRepository(\Box\Mod\Order\Entity\Order::class)->find($event->orderId); if (!$orderModel instanceof \Box\Mod\Order\Entity\Order) { return; } @@ -289,13 +284,13 @@ class Service implements InjectionAwareInterface } } - public static function onAfterAdminOrderSuspend(\Box_Event $event): void + #[AsEventListener] + public function notifyStaffAfterOrderSuspend(AfterAdminOrderSuspendEvent $event): void { - $di = $event->getDi(); - $params = $event->getParameters(); + $di = $this->di ?? throw new \LogicException('Staff service must be initialized before handling events.'); try { - $order = $di['em']->getRepository(\Box\Mod\Order\Entity\Order::class)->find((int) $params['id']); + $order = $di['em']->getRepository(\Box\Mod\Order\Entity\Order::class)->find($event->orderId); if (!$order instanceof \Box\Mod\Order\Entity\Order) { throw new \FOSSBilling\Exception('Order not found'); } @@ -314,14 +309,18 @@ class Service implements InjectionAwareInterface } } - public static function onAfterClientOpenTicket(\Box_Event $event): void + #[AsEventListener] + public function notifyStaffAfterTicketOpened(AfterTicketOpenedEvent $event): void { - $di = $event->getDi(); - $params = $event->getParameters(); + if ($event->actor === TicketActorRole::ADMIN) { + return; + } + + $di = $this->di ?? throw new \LogicException('The Staff service dependency injection container has not been set.'); try { $supportTicketService = $di['mod_service']('support'); - $ticketModel = $supportTicketService->getTicketById((int) $params['id']); + $ticketModel = $supportTicketService->getTicketById($event->ticketId); $ticket = self::getTicketEmailVars($di, $ticketModel); $helpdeskId = $ticketModel->getSupportHelpdeskId(); @@ -347,14 +346,18 @@ class Service implements InjectionAwareInterface } } - public static function onAfterClientReplyTicket(\Box_Event $event): void + #[AsEventListener] + public function notifyStaffAfterTicketReplied(AfterTicketRepliedEvent $event): void { - $params = $event->getParameters(); - $di = $event->getDi(); + if ($event->actor === TicketActorRole::ADMIN) { + return; + } + + $di = $this->di ?? throw new \LogicException('The Staff service dependency injection container has not been set.'); try { $supportTicketService = $di['mod_service']('support'); - $ticketModel = $supportTicketService->getTicketById((int) $params['id']); + $ticketModel = $supportTicketService->getTicketById($event->ticketId); $ticket = self::getTicketEmailVars($di, $ticketModel); $email = []; @@ -369,14 +372,18 @@ class Service implements InjectionAwareInterface } } - public static function onAfterClientCloseTicket(\Box_Event $event): void + #[AsEventListener] + public function notifyStaffAfterTicketClosed(AfterTicketClosedEvent $event): void { - $params = $event->getParameters(); - $di = $event->getDi(); + if ($event->actor === TicketActorRole::ADMIN) { + return; + } + + $di = $this->di ?? throw new \LogicException('The Staff service dependency injection container has not been set.'); try { $supportTicketService = $di['mod_service']('support'); - $ticketModel = $supportTicketService->getTicketById((int) $params['id']); + $ticketModel = $supportTicketService->getTicketById($event->ticketId); $ticket = self::getTicketEmailVars($di, $ticketModel); $email = []; $email['to_staff'] = true; @@ -416,10 +423,10 @@ class Service implements InjectionAwareInterface return $ticket; } - public static function onAfterClientSignUp(\Box_Event $event): bool + #[AsEventListener] + public function onAfterClientSignUp(AfterClientSignUpEvent $event): void { - $params = $event->getParameters(); - $di = $event->getDi(); + $di = $this->di ?? throw new \LogicException('Staff service must be initialized before handling events.'); try { $clientService = $di['mod_service']('client'); @@ -427,15 +434,13 @@ class Service implements InjectionAwareInterface $email = []; $email['to_staff'] = true; $email['code'] = 'mod_staff_client_signup'; - $client = $clientService->get(['id' => $params['id']]); + $client = $clientService->get(['id' => $event->clientId]); $email['c'] = $clientService->toApiArray($client); $emailService = $di['mod_service']('email'); $emailService->sendTemplate($email); } catch (\Exception $exc) { $di['logger']->withChannel('email')->error('Failed to send staff client signup notification email', ['exception' => $exc]); } - - return true; } public function getList($data) @@ -565,7 +570,7 @@ class Service implements InjectionAwareInterface public function update(Admin $model, $data): bool { - $this->di['events_manager']->fire(['event' => 'onBeforeAdminStaffUpdate', 'params' => ['id' => $model->getId()]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminStaffUpdateEvent((int) $model->getId())); $this->checkPermissionsAndThrowException('staff', 'create_and_edit_staff'); @@ -608,7 +613,7 @@ class Service implements InjectionAwareInterface $profileService->invalidateSessions('admin', (int) $model->getId()); } - $this->di['events_manager']->fire(['event' => 'onAfterAdminStaffUpdate', 'params' => ['id' => $model->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminStaffUpdateEvent((int) $model->getId())); $this->di['logger']->info('Updated staff member #{model_id} "{model_name}" details; status is "{model_status}"', ['model_id' => $model->getId(), 'model_name' => $model->getName(), 'model_status' => $model->getStatus()]); @@ -626,7 +631,7 @@ class Service implements InjectionAwareInterface $this->assertCanRemoveActiveSuperAdministrator($model); - $this->di['events_manager']->fire(['event' => 'onBeforeAdminStaffDelete', 'params' => ['id' => $model->getId()]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminStaffDeleteEvent((int) $model->getId())); $id = $model->getId(); $name = $model->getName(); @@ -637,7 +642,7 @@ class Service implements InjectionAwareInterface $this->di['em']->flush(); }); - $this->di['events_manager']->fire(['event' => 'onAfterAdminStaffDelete', 'params' => ['id' => $id]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminStaffDeleteEvent((int) $id)); $this->di['logger']->info('Deleted staff member #{id} "{name}"', ['id' => $id, 'name' => $name]); @@ -649,7 +654,7 @@ class Service implements InjectionAwareInterface $this->checkPermissionsAndThrowException('staff', 'reset_staff_password'); $this->assertCanManageAdmin($model); - $this->di['events_manager']->fire(['event' => 'onBeforeAdminStaffPasswordChange', 'params' => ['id' => $model->getId()]]); + $this->di['event_dispatcher']->dispatch(new BeforeAdminStaffPasswordChangeEvent((int) $model->getId())); $model->setPass($this->di['password']->hashIt($password)); $this->di['em']->persist($model); @@ -658,7 +663,7 @@ class Service implements InjectionAwareInterface $profileService = $this->di['mod_service']('profile'); $profileService->invalidateSessions('admin', (int) $model->getId()); - $this->di['events_manager']->fire(['event' => 'onAfterAdminStaffPasswordChange', 'params' => ['id' => $model->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminStaffPasswordChangeEvent((int) $model->getId())); $this->di['logger']->info('Changed password for staff member #{model_id} "{model_name}"', ['model_id' => $model->getId(), 'model_name' => $model->getName()]); @@ -682,7 +687,8 @@ class Service implements InjectionAwareInterface $this->assertCanManageGroup($group); - $this->di['events_manager']->fire(['event' => 'onBeforeAdminStaffCreate', 'params' => $data]); + $eventInput = array_intersect_key($data, array_flip(['email', 'name', 'status', 'signature', 'timezone', 'group_id'])); + $this->di['event_dispatcher']->dispatch(new BeforeAdminStaffCreateEvent($eventInput)); $model = new Admin(); $model->setEmail($data['email']); @@ -705,7 +711,7 @@ class Service implements InjectionAwareInterface $newId = (int) $model->getId(); - $this->di['events_manager']->fire(['event' => 'onAfterAdminStaffCreate', 'params' => ['id' => $newId]]); + $this->di['event_dispatcher']->dispatch(new AfterAdminStaffCreateEvent($newId)); $this->di['logger']->info('Created staff member #{admin_id} "{model_name}" in group #{group_id} "{group_name}"', ['admin_id' => $newId, 'model_name' => $model->getName(), 'group_id' => $groupId, 'group_name' => $group->getName()]); diff --git a/src/modules/Staff/tests/Unit/Api/GuestTest.php b/src/modules/Staff/tests/Unit/Api/GuestTest.php index 385389f4e..325905ae6 100644 --- a/src/modules/Staff/tests/Unit/Api/GuestTest.php +++ b/src/modules/Staff/tests/Unit/Api/GuestTest.php @@ -14,6 +14,21 @@ use function Tests\Helpers\container; use function Tests\Helpers\createEntity; use function Tests\Helpers\moduleService; +function staffGuestTestEventDispatcher(): object +{ + return new class { + /** @var list */ + public array $dispatched = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->dispatched[] = $event; + + return $event; + } + }; +} + test('get di', function (): void { $api = apiEndpoint(new Box\Mod\Staff\Api\Guest()); $di = container(); @@ -51,6 +66,86 @@ test('password reset requires an email', function (): void { ->toThrow(FOSSBilling\InformationException::class, 'Email required'); }); +test('password reset request dispatches only IP metadata before email validation', function (): void { + $guestApi = apiEndpoint(new Box\Mod\Staff\Api\Guest()); + $modMock = Mockery::mock('\\' . FOSSBilling\Module::class); + $modMock->shouldReceive('getConfig')->once()->andReturn([]); + + $eventDispatcher = staffGuestTestEventDispatcher(); + $toolsMock = Mockery::mock(FOSSBilling\Tools::class); + $toolsMock->shouldReceive('validateAndSanitizeEmail') + ->once() + ->with('private@example.com') + ->andReturnUsing(function () use ($eventDispatcher): string { + expect($eventDispatcher->dispatched)->toHaveCount(1); + expect($eventDispatcher->dispatched[0])->toBeInstanceOf(Box\Mod\Staff\Event\BeforeStaffPasswordResetRequestEvent::class); + + return 'private@example.com'; + }); + + $rateLimiter = Mockery::mock(FOSSBilling\Security\RateLimiter::class); + $rateLimiter->shouldReceive('consume') + ->once() + ->with('staff_password_reset_ip', '192.0.2.11') + ->andReturn(new FOSSBilling\Security\RateLimitResult('staff_password_reset_ip', true, 5, 0)); + + $di = container(); + $di['event_dispatcher'] = $eventDispatcher; + $di['tools'] = $toolsMock; + $di['rate_limiter'] = $rateLimiter; + $di['logger'] = new Tests\Helpers\TestLogger(); + + $guestApi->setMod($modMock); + $guestApi->setDi($di); + $guestApi->setIp('192.0.2.11'); + + expect($guestApi->passwordreset(['email' => 'private@example.com']))->toBeTrue(); + expect($eventDispatcher->dispatched)->toHaveCount(1); + expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Staff\Event\BeforeStaffPasswordResetRequestEvent('192.0.2.11')); + expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.11']); +}); + +test('password reset confirmation dispatches before required-field validation', function (): void { + $guestApi = apiEndpoint(new Box\Mod\Staff\Api\Guest()); + $modMock = Mockery::mock('\\' . FOSSBilling\Module::class); + $modMock->shouldReceive('getConfig')->once()->andReturn([]); + + $eventDispatcher = staffGuestTestEventDispatcher(); + $rateLimiter = Mockery::mock(FOSSBilling\Security\RateLimiter::class); + $rateLimiter->shouldReceive('consumeOrThrow') + ->once() + ->with('staff_password_reset_confirm_post_ip', '192.0.2.12') + ->andReturn(new FOSSBilling\Security\RateLimitResult('staff_password_reset_confirm_post_ip', false, 5, 4)); + + $validator = Mockery::mock(FOSSBilling\Validate::class); + $validator->shouldReceive('checkRequiredParamsForArray')->once()->andReturnUsing(function () use ($eventDispatcher): void { + expect($eventDispatcher->dispatched)->toHaveCount(1); + expect($eventDispatcher->dispatched[0])->toBeInstanceOf(Box\Mod\Staff\Event\BeforeStaffPasswordResetConfirmationEvent::class); + + throw new FOSSBilling\InformationException('Code required'); + }); + + $di = container(); + $di['event_dispatcher'] = $eventDispatcher; + $di['rate_limiter'] = $rateLimiter; + $di['validator'] = $validator; + $di['logger'] = new Tests\Helpers\TestLogger(); + + $guestApi->setMod($modMock); + $guestApi->setDi($di); + $guestApi->setIp('192.0.2.12'); + + expect(fn () => $guestApi->update_password([ + 'code' => 'private-reset-code', + 'password' => 'private-password', + 'password_confirm' => 'private-password', + ]))->toThrow(FOSSBilling\InformationException::class, 'Code required'); + + expect($eventDispatcher->dispatched)->toHaveCount(1); + expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Staff\Event\BeforeStaffPasswordResetConfirmationEvent('192.0.2.12')); + expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.12']); +}); + test('successful login', function (): void { $api = apiEndpoint(new Box\Mod\Staff\Api\Guest()); $modMock = Mockery::mock('\\' . FOSSBilling\Module::class); @@ -130,14 +225,16 @@ test('updatePassword invalidates existing sessions', function (): void { $passwordResetRepository = Mockery::mock(Box\Mod\Staff\Repository\AdminPasswordResetRepository::class); $passwordResetRepository->shouldReceive('findOneByHash')->once()->with('hashedString')->andReturn($passwordReset); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->times(2); + $eventDispatcher = staffGuestTestEventDispatcher(); $passwordMock = Mockery::mock(FOSSBilling\PasswordManager::class); $passwordMock->shouldReceive('hashIt')->atLeast()->once(); $emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class); - $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once(); + $emailServiceMock->shouldReceive('sendTemplate')->once()->andReturnUsing(function () use ($eventDispatcher): void { + expect($eventDispatcher->dispatched)->toHaveCount(2); + expect($eventDispatcher->dispatched[1])->toBeInstanceOf(Box\Mod\Staff\Event\AfterStaffPasswordResetEvent::class); + }); $profileServiceMock = Mockery::mock(Box\Mod\Profile\Service::class); $profileServiceMock->shouldReceive('invalidateSessions')->atLeast()->once(); @@ -145,19 +242,29 @@ test('updatePassword invalidates existing sessions', function (): void { $di = container(); $di['em']->shouldReceive('getRepository')->with(Box\Mod\Staff\Entity\AdminPasswordReset::class)->andReturn($passwordResetRepository); $di['em']->shouldReceive('persist')->once()->with($admin); - $di['em']->shouldReceive('remove')->once()->with($passwordReset); + $di['em']->shouldReceive('remove')->once()->with($passwordReset)->andReturnUsing(function () use ($eventDispatcher): void { + expect($eventDispatcher->dispatched)->toHaveCount(2); + expect($eventDispatcher->dispatched[1])->toBeInstanceOf(Box\Mod\Staff\Event\AfterStaffPasswordResetEvent::class); + }); $di['em']->shouldReceive('flush')->atLeast()->once(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = new Tests\Helpers\TestLogger(); $di['password'] = $passwordMock; $di['mod_service'] = $di->protect(moduleService(['email' => $emailServiceMock, 'profile' => $profileServiceMock])); $guestApi->setMod($modMock); $guestApi->setDi($di); + $guestApi->setIp('192.0.2.10'); $guestApi->update_password([ 'code' => 'hashedString', 'password' => 'NewPassword1', 'password_confirm' => 'NewPassword1', ]); + + expect($eventDispatcher->dispatched)->toHaveCount(2); + expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Staff\Event\BeforeStaffPasswordResetConfirmationEvent('192.0.2.10')); + expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.10']); + expect($eventDispatcher->dispatched[1])->toEqual(new Box\Mod\Staff\Event\AfterStaffPasswordResetEvent(1)); + expect(get_object_vars($eventDispatcher->dispatched[1]))->toBe(['adminId' => 1]); }); diff --git a/src/modules/Staff/tests/Unit/Controller/AdminTest.php b/src/modules/Staff/tests/Unit/Controller/AdminTest.php new file mode 100644 index 000000000..ea74d8c9a --- /dev/null +++ b/src/modules/Staff/tests/Unit/Controller/AdminTest.php @@ -0,0 +1,61 @@ + */ + public array $dispatched = []; + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->dispatched[] = $event; + + return $event; + } + }; +} + +test('get update password dispatches the confirmation event after rate limiting', function (): void { + $controller = new Admin(); + $eventDispatcher = staffAdminControllerTestEventDispatcher(); + $moduleMock = Mockery::mock(FOSSBilling\Module::class); + $moduleMock->shouldReceive('getConfig')->once()->andReturnUsing(function () use ($eventDispatcher): array { + expect($eventDispatcher->dispatched)->toHaveCount(1); + expect($eventDispatcher->dispatched[0])->toBeInstanceOf(Box\Mod\Staff\Event\BeforeStaffPasswordResetConfirmationEvent::class); + + return ['public' => ['reset_pw' => '0']]; + }); + + $rateLimiter = Mockery::mock(FOSSBilling\Security\RateLimiter::class); + $rateLimiter->shouldReceive('consume') + ->once() + ->with('staff_password_reset_confirm_ip', '192.0.2.14') + ->andReturn(new FOSSBilling\Security\RateLimitResult('staff_password_reset_confirm_ip', false, 5, 4)); + + $di = container(); + $di['event_dispatcher'] = $eventDispatcher; + $di['rate_limiter'] = $rateLimiter; + $di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '192.0.2.14']); + $di['mod'] = $di->protect(fn (string $name): FOSSBilling\Module => $moduleMock); + $controller->setDi($di); + + $app = Mockery::mock('\\Box_App'); + $app->shouldReceive('render')->once()->with('mod_staff_password_reset')->andReturn('reset page'); + + expect($controller->get_updatepassword($app, 'private-reset-code'))->toBe('reset page'); + expect($eventDispatcher->dispatched)->toHaveCount(1); + expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Staff\Event\BeforeStaffPasswordResetConfirmationEvent('192.0.2.14')); + expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.14']); +}); diff --git a/src/modules/Staff/tests/Unit/ServiceTest.php b/src/modules/Staff/tests/Unit/ServiceTest.php index 9e24bdef0..2c8db16a9 100644 --- a/src/modules/Staff/tests/Unit/ServiceTest.php +++ b/src/modules/Staff/tests/Unit/ServiceTest.php @@ -11,18 +11,36 @@ declare(strict_types=1); use Box\Mod\Activity\Entity\ActivityAdminHistory; +use Box\Mod\Client\Event\AfterClientSignUpEvent; use Box\Mod\Staff\Entity\Admin; use Box\Mod\Staff\Entity\AdminGroup; use Box\Mod\Staff\Entity\AdminGroupMember; use Box\Mod\Staff\Entity\AdminPasswordReset; +use Box\Mod\Staff\Event\AdminLoginFailedEvent; +use Box\Mod\Staff\Event\AfterAdminLoginEvent; +use Box\Mod\Staff\Event\AfterAdminStaffCreateEvent; +use Box\Mod\Staff\Event\AfterAdminStaffDeleteEvent; +use Box\Mod\Staff\Event\AfterAdminStaffPasswordChangeEvent; +use Box\Mod\Staff\Event\AfterAdminStaffUpdateEvent; +use Box\Mod\Staff\Event\BeforeAdminLoginEvent; +use Box\Mod\Staff\Event\BeforeAdminStaffCreateEvent; +use Box\Mod\Staff\Event\BeforeAdminStaffDeleteEvent; +use Box\Mod\Staff\Event\BeforeAdminStaffPasswordChangeEvent; +use Box\Mod\Staff\Event\BeforeAdminStaffUpdateEvent; use Box\Mod\Staff\Repository\AdminGroupMemberRepository; use Box\Mod\Staff\Repository\AdminGroupRepository; use Box\Mod\Staff\Repository\AdminPasswordResetRepository; use Box\Mod\Staff\Repository\AdminRepository; use Box\Mod\Staff\Service; use Box\Mod\Support\Entity\Helpdesk; +use Box\Mod\Support\Event\AfterTicketClosedEvent; +use Box\Mod\Support\Event\AfterTicketOpenedEvent; +use Box\Mod\Support\Event\AfterTicketRepliedEvent; +use Box\Mod\Support\Event\TicketActorRole; use Box\Mod\Support\Repository\HelpdeskRepository; use Doctrine\ORM\EntityManagerInterface; +use FOSSBilling\Events\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher; use function Tests\Helpers\container; use function Tests\Helpers\createEntity; @@ -74,6 +92,73 @@ function staffServiceWithGroupPermissions(array $groups = [], bool $isSuperAdmin return $service; } +function dispatchStaffTicketEvent(Service $service, Pimple\Container $di, string $action, TicketActorRole $actor = TicketActorRole::CLIENT, int $ticketId = 42): void +{ + if ($service->getDi() !== $di) { + $service->setDi($di); + } + + $dispatcher = new EventDispatcher( + static fn (): array => ['staff'], + static fn (string $module): Service => $service, + ); + $event = match ($action) { + 'opened' => new AfterTicketOpenedEvent($ticketId, $actor), + 'replied' => new AfterTicketRepliedEvent($ticketId, $actor), + 'closed' => new AfterTicketClosedEvent($ticketId, $actor), + default => throw new InvalidArgumentException(sprintf('Unknown support ticket action "%s".', $action)), + }; + + $dispatcher->dispatch($event); +} + +/** @return array{SymfonyEventDispatcher, ArrayObject} */ +function staffLoginEventDispatcher(): array +{ + $events = new ArrayObject(); + $dispatcher = new SymfonyEventDispatcher(); + + foreach ([BeforeAdminLoginEvent::class, AdminLoginFailedEvent::class, AfterAdminLoginEvent::class] as $eventClass) { + $dispatcher->addListener($eventClass, static function (object $event) use ($events): void { + $events->append($event); + }); + } + + return [$dispatcher, $events]; +} + +/** @return array{SymfonyEventDispatcher, ArrayObject} */ +function staffAccountCrudEventDispatcher(): array +{ + $events = new ArrayObject(); + $dispatcher = new SymfonyEventDispatcher(); + + foreach ([ + BeforeAdminStaffCreateEvent::class, + AfterAdminStaffCreateEvent::class, + BeforeAdminStaffUpdateEvent::class, + AfterAdminStaffUpdateEvent::class, + BeforeAdminStaffDeleteEvent::class, + AfterAdminStaffDeleteEvent::class, + BeforeAdminStaffPasswordChangeEvent::class, + AfterAdminStaffPasswordChangeEvent::class, + ] as $eventClass) { + $dispatcher->addListener($eventClass, static function (object $event) use ($events): void { + $events->append($event); + }); + } + + return [$dispatcher, $events]; +} + +function staffLoginEventSummaries(ArrayObject $events): array +{ + return array_map( + static fn (object $event): array => [$event::class, get_object_vars($event)], + iterator_to_array($events), + ); +} + function staffSetEntityId(object $entity, int $id): void { $property = new ReflectionProperty($entity, 'id'); @@ -149,9 +234,7 @@ test('login returns admin details on successful login', function (): void { $admin = \Tests\Helpers\admin(['id' => 1, 'email' => $email, 'name' => 'Admin', 'pass' => 'hashedPassword']); - $emMock = Mockery::mock('\Box_EventManager'); - $emMock->shouldReceive('fire')->atLeast()->once() - ->andReturn(true); + [$eventDispatcher, $dispatchedEvents] = staffLoginEventDispatcher(); $adminRepository = Mockery::mock(AdminRepository::class); $adminRepository->shouldReceive('findOneByEmailAndActive')->atLeast()->once() @@ -170,7 +253,7 @@ test('login returns admin details on successful login', function (): void { ->andReturn(false); $di = container(); - $di['events_manager'] = $emMock; + $di['event_dispatcher'] = $eventDispatcher; $di['em']->shouldReceive('getRepository')->with(Admin::class)->andReturn($adminRepository); $di['session'] = $sessionMock; $di['logger'] = new Tests\Helpers\TestLogger(); @@ -187,118 +270,10 @@ test('login returns admin details on successful login', function (): void { 'name' => 'Admin', ]; - expect($result)->toBe($expected); -}); - -test('login retries connecting event listeners once before firing the login event', function (): void { - $email = 'email@domain.com'; - $password = 'pass'; - $ip = '127.0.0.1'; - - $admin = \Tests\Helpers\admin(['id' => 1, 'email' => $email, 'name' => 'Admin', 'pass' => 'hashedPassword']); - - $emMock = Mockery::mock('\Box_EventManager'); - $emMock->shouldReceive('fire')->atLeast()->once() - ->andReturn(true); - - $adminRepository = Mockery::mock(AdminRepository::class); - $adminRepository->shouldReceive('findOneByEmailAndActive')->atLeast()->once() - ->with($email) - ->andReturn($admin); - - $sessionMock = Mockery::mock(FOSSBilling\Session::class); - $sessionMock->shouldReceive('regenerateId')->atLeast()->once(); - $sessionMock->shouldReceive('set')->atLeast()->once(); - - $passwordMock = Mockery::mock(FOSSBilling\PasswordManager::class); - $passwordMock->shouldReceive('verify')->atLeast()->once() - ->with($password, $admin->getPass()) - ->andReturn(true); - $passwordMock->shouldReceive('needsRehash')->atLeast()->once() - ->andReturn(false); - - // First attempt fails, as if another process held the rebuild lock past its timeout. - $hookServiceMock = Mockery::mock(Box\Mod\Hook\Service::class); - $hookServiceMock->shouldReceive('hasConnectedListeners')->atLeast()->once()->andReturn(false); - $hookServiceMock->shouldReceive('batchConnect')->twice()->andReturn(false, true); - - $di = container(); - $di['events_manager'] = $emMock; - $di['em']->shouldReceive('getRepository')->with(Admin::class)->andReturn($adminRepository); - $di['session'] = $sessionMock; - $di['logger'] = new Tests\Helpers\TestLogger(); - $di['password'] = $passwordMock; - $di['mod_service'] = $di->protect(fn (string $name = ''): object => strtolower($name) === 'hook' ? $hookServiceMock : Mockery::mock()->shouldIgnoreMissing()); - - $service = new Service(); - $service->setDi($di); - - $result = $service->login($email, $password, $ip); - - expect($result)->toBe([ - 'id' => 1, - 'email' => $email, - 'name' => 'Admin', - ]); -}); - -test('login still succeeds, and logs a warning, when both attempts to connect event listeners fail', function (): void { - $email = 'email@domain.com'; - $password = 'pass'; - $ip = '127.0.0.1'; - - $admin = \Tests\Helpers\admin(['id' => 1, 'email' => $email, 'name' => 'Admin', 'pass' => 'hashedPassword']); - - $emMock = Mockery::mock('\Box_EventManager'); - $emMock->shouldReceive('fire')->atLeast()->once() - ->andReturn(true); - - $adminRepository = Mockery::mock(AdminRepository::class); - $adminRepository->shouldReceive('findOneByEmailAndActive')->atLeast()->once() - ->with($email) - ->andReturn($admin); - - $sessionMock = Mockery::mock(FOSSBilling\Session::class); - $sessionMock->shouldReceive('regenerateId')->atLeast()->once(); - $sessionMock->shouldReceive('set')->atLeast()->once(); - - $passwordMock = Mockery::mock(FOSSBilling\PasswordManager::class); - $passwordMock->shouldReceive('verify')->atLeast()->once() - ->with($password, $admin->getPass()) - ->andReturn(true); - $passwordMock->shouldReceive('needsRehash')->atLeast()->once() - ->andReturn(false); - - // Both attempts fail to connect listeners, e.g. another process holding the rebuild lock - // for longer than either attempt's timeout. Login must still succeed - it must not become - // unavailable because of this housekeeping step. - $hookServiceMock = Mockery::mock(Box\Mod\Hook\Service::class); - $hookServiceMock->shouldReceive('hasConnectedListeners')->atLeast()->once()->andReturn(false); - $hookServiceMock->shouldReceive('batchConnect')->twice()->andReturn(false, false); - - $loggerMock = new Tests\Helpers\TestLogger(); - - $di = container(); - $di['events_manager'] = $emMock; - $di['em']->shouldReceive('getRepository')->with(Admin::class)->andReturn($adminRepository); - $di['session'] = $sessionMock; - $di['logger'] = $loggerMock; - $di['password'] = $passwordMock; - $di['mod_service'] = $di->protect(fn (string $name = ''): object => strtolower($name) === 'hook' ? $hookServiceMock : Mockery::mock()->shouldIgnoreMissing()); - - $service = new Service(); - $service->setDi($di); - - $result = $service->login($email, $password, $ip); - - expect($result)->toBe([ - 'id' => 1, - 'email' => $email, - 'name' => 'Admin', - ]) - ->and($loggerMock->calls)->toContain([ - 'method' => 'warning', - 'params' => ['Could not connect event listeners after two attempts; this login (and other events) may not be recorded.'], + expect($result)->toBe($expected) + ->and(staffLoginEventSummaries($dispatchedEvents))->toBe([ + [BeforeAdminLoginEvent::class, ['ip' => $ip]], + [AfterAdminLoginEvent::class, ['adminId' => 1, 'ip' => $ip]], ]); }); @@ -307,9 +282,7 @@ test('login throws exception when credentials are invalid', function (): void { $password = 'pass'; $ip = '127.0.0.1'; - $emMock = Mockery::mock('\Box_EventManager'); - $emMock->shouldReceive('fire')->atLeast()->once() - ->andReturn(true); + [$eventDispatcher, $dispatchedEvents] = staffLoginEventDispatcher(); $adminRepository = Mockery::mock(AdminRepository::class); $adminRepository->shouldReceive('findOneByEmailAndActive')->atLeast()->once() @@ -321,7 +294,7 @@ test('login throws exception when credentials are invalid', function (): void { ->with($password); $di = container(); - $di['events_manager'] = $emMock; + $di['event_dispatcher'] = $eventDispatcher; $di['em']->shouldReceive('getRepository')->with(Admin::class)->andReturn($adminRepository); $di['password'] = $passwordMock; @@ -330,6 +303,11 @@ test('login throws exception when credentials are invalid', function (): void { expect(fn (): array => $service->login($email, $password, $ip)) ->toThrow(FOSSBilling\Exception::class, 'Check your login details'); + + expect(staffLoginEventSummaries($dispatchedEvents))->toBe([ + [BeforeAdminLoginEvent::class, ['ip' => $ip]], + [AdminLoginFailedEvent::class, ['ip' => $ip]], + ]); }); test('hasPermission returns true for super administrator group member', function (): void { @@ -436,6 +414,9 @@ test('onAfterAdminOrderSuspend sends a staff notification', function (): void { $orderRepository->shouldReceive('find')->once()->with(42)->andReturn($order); $entityManager = Mockery::mock(EntityManagerInterface::class); + $entityManager->shouldReceive('getRepository')->with(AdminGroup::class)->andReturn(Mockery::mock(AdminGroupRepository::class)); + $entityManager->shouldReceive('getRepository')->with(AdminGroupMember::class)->andReturn(Mockery::mock(AdminGroupMemberRepository::class)); + $entityManager->shouldReceive('getRepository')->with(AdminPasswordReset::class)->andReturn(Mockery::mock(AdminPasswordResetRepository::class)); $entityManager->shouldReceive('getRepository') ->once() ->with(Box\Mod\Order\Entity\Order::class) @@ -459,15 +440,49 @@ test('onAfterAdminOrderSuspend sends a staff notification', function (): void { 'email' => $emailService, }); - $event = Mockery::mock(Box_Event::class); - $event->shouldReceive('getDi')->once()->andReturn($di); - $event->shouldReceive('getParameters')->once()->andReturn(['id' => 42]); - - Service::onAfterAdminOrderSuspend($event); + $service = new Service(); + $service->setDi($di); + $service->notifyStaffAfterOrderSuspend(new Box\Mod\Order\Event\AfterAdminOrderSuspendEvent(42)); }); -test('onAfterClientReplyTicket limits client details in the email notification', function (): void { - $eventMock = Mockery::mock('\Box_Event'); +test('typed client order creation sends a staff notification', function (): void { + $order = Tests\Helpers\createEntity(Box\Mod\Order\Entity\Order::class, ['id' => 42]); + $orderRepository = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class); + $orderRepository->shouldReceive('find')->once()->with(42)->andReturn($order); + + $entityManager = Mockery::mock(EntityManagerInterface::class); + $entityManager->shouldReceive('getRepository')->with(AdminGroup::class)->andReturn(Mockery::mock(AdminGroupRepository::class)); + $entityManager->shouldReceive('getRepository')->with(AdminGroupMember::class)->andReturn(Mockery::mock(AdminGroupMemberRepository::class)); + $entityManager->shouldReceive('getRepository')->with(AdminPasswordReset::class)->andReturn(Mockery::mock(AdminPasswordResetRepository::class)); + $entityManager->shouldReceive('getRepository') + ->once() + ->with(Box\Mod\Order\Entity\Order::class) + ->andReturn($orderRepository); + + $orderData = ['id' => 42, 'title' => 'Hosting']; + $orderService = Mockery::mock(Box\Mod\Order\Service::class); + $orderService->shouldReceive('toApiArray')->once()->with($order, true)->andReturn($orderData); + + $emailService = Mockery::mock(Box\Mod\Email\Service::class); + $emailService->shouldReceive('sendTemplate')->once()->with([ + 'to_staff' => true, + 'code' => 'mod_staff_client_order', + 'order' => $orderData, + ]); + + $di = container(); + $di['em'] = $entityManager; + $di['mod_service'] = $di->protect(fn (string $name): object => match ($name) { + 'order' => $orderService, + 'email' => $emailService, + }); + + $service = new Service(); + $service->setDi($di); + $service->notifyStaffAfterClientOrderCreate(new Box\Mod\Order\Event\AfterClientOrderCreateEvent(42, 7, '192.0.2.7')); +}); + +test('typed ticket replied event limits client details in the email notification', function (): void { $ticketId = 42; $clientId = 7; $ticketModel = (new Box\Mod\Support\Entity\SupportTicket()) @@ -520,9 +535,6 @@ test('onAfterClientReplyTicket limits client details in the email notification', ], ]); - $eventMock->shouldReceive('getParameters')->once() - ->andReturn(['id' => $ticketId]); - $service = new Service(); $di = container(); @@ -537,15 +549,11 @@ test('onAfterClientReplyTicket limits client details in the email notification', return $emailServiceMock; } }); - - $eventMock->shouldReceive('getDi')->atLeast()->once() - ->andReturn($di); $service->setDi($di); - $service->onAfterClientReplyTicket($eventMock); + dispatchStaffTicketEvent($service, $di, 'replied'); }); -test('onAfterClientReplyTicket still sends when its client no longer exists', function (): void { - $eventMock = Mockery::mock('\\Box_Event'); +test('typed ticket replied event still sends when its client no longer exists', function (): void { $ticketId = 42; $clientId = 7; $ticketModel = (new Box\Mod\Support\Entity\SupportTicket()) @@ -583,15 +591,11 @@ test('onAfterClientReplyTicket still sends when its client no longer exists', fu 'email' => $emailServiceMock, }); - $eventMock->shouldReceive('getParameters')->once()->andReturn(['id' => $ticketId]); - $eventMock->shouldReceive('getDi')->once()->andReturn($di); - - Service::onAfterClientReplyTicket($eventMock); + $service = new Service(); + dispatchStaffTicketEvent($service, $di, 'replied'); }); -test('onAfterClientReplyTicket handles email exception', function (): void { - $eventMock = Mockery::mock('\Box_Event'); - +test('typed ticket replied event handles email exception', function (): void { $supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class); $supportServiceMock->shouldReceive('getTicketById')->atLeast()->once() ->andReturn(new Box\Mod\Support\Entity\SupportTicket()); @@ -602,9 +606,6 @@ test('onAfterClientReplyTicket handles email exception', function (): void { $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once() ->andThrow(new Exception('PHPunit controlled Exception')); - $eventMock->shouldReceive('getparameters')->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - $service = new Service(); $di = container(); @@ -616,15 +617,11 @@ test('onAfterClientReplyTicket handles email exception', function (): void { return $emailServiceMock; } }); - - $eventMock->shouldReceive('getDi')->atLeast()->once() - ->andReturn($di); $service->setDi($di); - $service->onAfterClientReplyTicket($eventMock); + dispatchStaffTicketEvent($service, $di, 'replied'); }); -test('onAfterClientCloseTicket sends email notification', function (): void { - $eventMock = Mockery::mock('\Box_Event'); +test('typed ticket closed event sends email notification', function (): void { $ticketId = 42; $clientId = 7; $ticketModel = (new Box\Mod\Support\Entity\SupportTicket()) @@ -675,9 +672,6 @@ test('onAfterClientCloseTicket sends email notification', function (): void { ], ]); - $eventMock->shouldReceive('getparameters')->atLeast()->once() - ->andReturn(['id' => $ticketId]); - $service = new Service(); $di = container(); @@ -692,16 +686,11 @@ test('onAfterClientCloseTicket sends email notification', function (): void { return $emailServiceMock; } }); - - $eventMock->shouldReceive('getDi')->atLeast()->once() - ->andReturn($di); $service->setDi($di); - $service->onAfterClientCloseTicket($eventMock); + dispatchStaffTicketEvent($service, $di, 'closed'); }); -test('onAfterClientCloseTicket handles email exception', function (): void { - $eventMock = Mockery::mock('\Box_Event'); - +test('typed ticket closed event handles email exception', function (): void { $supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class); $supportServiceMock->shouldReceive('getTicketById')->atLeast()->once() ->andReturn(new Box\Mod\Support\Entity\SupportTicket()); @@ -712,9 +701,6 @@ test('onAfterClientCloseTicket handles email exception', function (): void { $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once() ->andThrow(new Exception('PHPunit controlled Exception')); - $eventMock->shouldReceive('getparameters')->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - $service = new Service(); $di = container(); @@ -726,16 +712,11 @@ test('onAfterClientCloseTicket handles email exception', function (): void { return $emailServiceMock; } }); - - $eventMock->shouldReceive('getDi')->atLeast()->once() - ->andReturn($di); $service->setDi($di); - $service->onAfterClientCloseTicket($eventMock); + dispatchStaffTicketEvent($service, $di, 'closed'); }); -test('onAfterClientOpenTicket sends guest email notification', function (): void { - $eventMock = Mockery::mock('\Box_Event'); - +test('typed ticket opened event sends guest email notification', function (): void { $supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class); $supportServiceMock->shouldReceive('getTicketById')->atLeast()->once() ->andReturn(new Box\Mod\Support\Entity\SupportTicket()); @@ -746,9 +727,6 @@ test('onAfterClientOpenTicket sends guest email notification', function (): void $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once() ->with(Mockery::on(fn ($email): bool => $email['code'] === 'mod_staff_ticket_open')); - $eventMock->shouldReceive('getparameters')->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - $service = new Service(); $di = container(); @@ -760,16 +738,11 @@ test('onAfterClientOpenTicket sends guest email notification', function (): void return $emailServiceMock; } }); - - $eventMock->shouldReceive('getDi')->atLeast()->once() - ->andReturn($di); $service->setDi($di); - $service->onAfterClientOpenTicket($eventMock); + dispatchStaffTicketEvent($service, $di, 'opened', TicketActorRole::GUEST); }); -test('onAfterClientOpenTicket handles guest email exception', function (): void { - $eventMock = Mockery::mock('\Box_Event'); - +test('typed ticket opened event handles guest email exception', function (): void { $supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class); $supportServiceMock->shouldReceive('getTicketById')->atLeast()->once() ->andReturn(new Box\Mod\Support\Entity\SupportTicket()); @@ -780,9 +753,6 @@ test('onAfterClientOpenTicket handles guest email exception', function (): void $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once() ->andThrow(new Exception('PHPunit controlled Exception')); - $eventMock->shouldReceive('getparameters')->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - $service = new Service(); $di = container(); @@ -794,16 +764,11 @@ test('onAfterClientOpenTicket handles guest email exception', function (): void return $emailServiceMock; } }); - - $eventMock->shouldReceive('getDi')->atLeast()->once() - ->andReturn($di); $service->setDi($di); - $service->onAfterClientOpenTicket($eventMock); + dispatchStaffTicketEvent($service, $di, 'opened', TicketActorRole::GUEST); }); -test('onAfterClientReplyTicket sends guest email notification', function (): void { - $eventMock = Mockery::mock('\Box_Event'); - +test('typed ticket replied event sends guest email notification', function (): void { $supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class); $supportServiceMock->shouldReceive('getTicketById')->atLeast()->once() ->andReturn(new Box\Mod\Support\Entity\SupportTicket()); @@ -814,9 +779,6 @@ test('onAfterClientReplyTicket sends guest email notification', function (): voi $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once() ->with(Mockery::on(fn ($email): bool => $email['code'] === 'mod_staff_ticket_reply')); - $eventMock->shouldReceive('getparameters')->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - $service = new Service(); $di = container(); @@ -828,16 +790,11 @@ test('onAfterClientReplyTicket sends guest email notification', function (): voi return $emailServiceMock; } }); - - $eventMock->shouldReceive('getDi')->atLeast()->once() - ->andReturn($di); $service->setDi($di); - $service->onAfterClientReplyTicket($eventMock); + dispatchStaffTicketEvent($service, $di, 'replied', TicketActorRole::GUEST); }); -test('onAfterClientReplyTicket handles guest email exception', function (): void { - $eventMock = Mockery::mock('\Box_Event'); - +test('typed ticket replied event handles guest email exception', function (): void { $supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class); $supportServiceMock->shouldReceive('getTicketById')->atLeast()->once() ->andReturn(new Box\Mod\Support\Entity\SupportTicket()); @@ -848,9 +805,6 @@ test('onAfterClientReplyTicket handles guest email exception', function (): void $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once() ->andThrow(new Exception('PHPunit controlled Exception')); - $eventMock->shouldReceive('getparameters')->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - $service = new Service(); $di = container(); @@ -862,15 +816,11 @@ test('onAfterClientReplyTicket handles guest email exception', function (): void return $emailServiceMock; } }); - - $eventMock->shouldReceive('getDi')->atLeast()->once() - ->andReturn($di); $service->setDi($di); - $service->onAfterClientReplyTicket($eventMock); + dispatchStaffTicketEvent($service, $di, 'replied', TicketActorRole::GUEST); }); test('onAfterClientSignUp sends sanitized client details in the email variables', function (): void { - $eventMock = Mockery::mock('\Box_Event'); $clientId = 42; $client = createEntity(Box\Mod\Client\Entity\Client::class); $clientDetails = [ @@ -896,9 +846,6 @@ test('onAfterClientSignUp sends sanitized client details in the email variables' 'c' => $clientDetails, ]); - $eventMock->shouldReceive('getParameters')->once() - ->andReturn(['id' => $clientId]); - $service = new Service(); $di = container(); @@ -911,14 +858,11 @@ test('onAfterClientSignUp sends sanitized client details in the email variables' } }); - $eventMock->shouldReceive('getDi')->atLeast()->once() - ->andReturn($di); $service->setDi($di); - $service->onAfterClientSignUp($eventMock); + $service->onAfterClientSignUp(new AfterClientSignUpEvent($clientId)); }); test('onAfterClientSignUp handles email exception', function (): void { - $eventMock = Mockery::mock('\Box_Event'); $clientId = 42; $client = createEntity(Box\Mod\Client\Entity\Client::class); @@ -934,9 +878,6 @@ test('onAfterClientSignUp handles email exception', function (): void { $emailServiceMock->shouldReceive('sendTemplate')->once() ->andThrow(new Exception('PHPunit controlled Exception')); - $eventMock->shouldReceive('getParameters')->once() - ->andReturn(['id' => $clientId]); - $service = new Service(); $di = container(); @@ -949,15 +890,11 @@ test('onAfterClientSignUp handles email exception', function (): void { } }); - $eventMock->shouldReceive('getDi')->atLeast()->once() - ->andReturn($di); $service->setDi($di); - $service->onAfterClientSignUp($eventMock); + $service->onAfterClientSignUp(new AfterClientSignUpEvent($clientId)); }); -test('onAfterClientCloseTicket handles guest email exception', function (): void { - $eventMock = Mockery::mock('\Box_Event'); - +test('typed ticket closed event handles guest email exception', function (): void { $supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class); $supportServiceMock->shouldReceive('getTicketById')->atLeast()->once() ->andReturn(new Box\Mod\Support\Entity\SupportTicket()); @@ -968,9 +905,6 @@ test('onAfterClientCloseTicket handles guest email exception', function (): void $emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once() ->andThrow(new Exception('PHPunit controlled Exception')); - $eventMock->shouldReceive('getparameters')->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - $service = new Service(); $di = container(); @@ -982,20 +916,35 @@ test('onAfterClientCloseTicket handles guest email exception', function (): void return $emailServiceMock; } }); - $eventMock->shouldReceive('getDi')->atLeast()->once() - ->andReturn($di); $service->setDi($di); - $service->onAfterClientCloseTicket($eventMock); + dispatchStaffTicketEvent($service, $di, 'closed', TicketActorRole::GUEST); }); -test('onAfterClientOpenTicket sends mod_staff_ticket_open email', function (): void { +test('staff ignores ticket lifecycle events caused by an admin', function (): void { + $di = container(); + $moduleServiceRequests = []; + $di['mod_service'] = $di->protect(static function (string $name) use (&$moduleServiceRequests): object { + $moduleServiceRequests[] = $name; + + throw new LogicException(sprintf('Unexpected module service request: %s', $name)); + }); + + $service = new Service(); + foreach (['opened', 'replied', 'closed'] as $action) { + dispatchStaffTicketEvent($service, $di, $action, TicketActorRole::ADMIN); + } + + expect($moduleServiceRequests)->toBeEmpty(); +}); + +test('typed ticket opened event sends mod_staff_ticket_open email', function (): void { $di = container(); $clientId = 7; $ticketModel = new Box\Mod\Support\Entity\SupportTicket(); - \Tests\Helpers\setEntityId($ticketModel, 1); + staffSetEntityId($ticketModel, 1); $helpdesk = new Helpdesk(); - \Tests\Helpers\setEntityId($helpdesk, 1); + staffSetEntityId($helpdesk, 1); $ticketModel->setSupportHelpdesk($helpdesk); $ticketModel->setClientId($clientId); $clientModel = createEntity(Box\Mod\Client\Entity\Client::class); @@ -1063,6 +1012,9 @@ test('onAfterClientOpenTicket sends mod_staff_ticket_open email', function (): v $repoMock->shouldReceive('find')->atLeast()->once() ->andReturn(null); $emMock = Mockery::mock(EntityManagerInterface::class); + $emMock->shouldReceive('getRepository')->with(AdminGroup::class)->andReturn(Mockery::mock(AdminGroupRepository::class)->shouldIgnoreMissing()); + $emMock->shouldReceive('getRepository')->with(AdminGroupMember::class)->andReturn(Mockery::mock(AdminGroupMemberRepository::class)->shouldIgnoreMissing()); + $emMock->shouldReceive('getRepository')->with(AdminPasswordReset::class)->andReturn(Mockery::mock(AdminPasswordResetRepository::class)->shouldIgnoreMissing()); $emMock->shouldReceive('getRepository') ->with(Helpdesk::class) ->atLeast()->once() @@ -1071,25 +1023,18 @@ test('onAfterClientOpenTicket sends mod_staff_ticket_open email', function (): v $admin = \Tests\Helpers\admin(); $di['loggedin_admin'] = $admin; - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getDi')->atLeast()->once() - ->andReturn($di); - - $eventMock->shouldReceive('getparameters')->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - $service = new Service(); - $service->onAfterClientOpenTicket($eventMock); + dispatchStaffTicketEvent($service, $di, 'opened'); }); -test('onAfterClientOpenTicket sends mod_support_helpdesk_ticket_open email', function (): void { +test('typed ticket opened event sends mod_support_helpdesk_ticket_open email', function (): void { $di = container(); $clientId = 7; $ticketModel = new Box\Mod\Support\Entity\SupportTicket(); - \Tests\Helpers\setEntityId($ticketModel, 1); + staffSetEntityId($ticketModel, 1); $helpdesk = new Helpdesk(); - \Tests\Helpers\setEntityId($helpdesk, 1); + staffSetEntityId($helpdesk, 1); $ticketModel->setSupportHelpdesk($helpdesk); $ticketModel->setClientId($clientId); $clientModel = createEntity(Box\Mod\Client\Entity\Client::class); @@ -1158,6 +1103,9 @@ test('onAfterClientOpenTicket sends mod_support_helpdesk_ticket_open email', fun $repoMock->shouldReceive('find')->atLeast()->once() ->andReturn($helpdeskModel); $emMock = Mockery::mock(EntityManagerInterface::class); + $emMock->shouldReceive('getRepository')->with(AdminGroup::class)->andReturn(Mockery::mock(AdminGroupRepository::class)->shouldIgnoreMissing()); + $emMock->shouldReceive('getRepository')->with(AdminGroupMember::class)->andReturn(Mockery::mock(AdminGroupMemberRepository::class)->shouldIgnoreMissing()); + $emMock->shouldReceive('getRepository')->with(AdminPasswordReset::class)->andReturn(Mockery::mock(AdminPasswordResetRepository::class)->shouldIgnoreMissing()); $emMock->shouldReceive('getRepository') ->with(Helpdesk::class) ->atLeast()->once() @@ -1166,15 +1114,8 @@ test('onAfterClientOpenTicket sends mod_support_helpdesk_ticket_open email', fun $admin = \Tests\Helpers\admin(); $di['loggedin_admin'] = $admin; - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getDi')->atLeast()->once() - ->andReturn($di); - - $eventMock->shouldReceive('getparameters')->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - $service = new Service(); - $service->onAfterClientOpenTicket($eventMock); + dispatchStaffTicketEvent($service, $di, 'opened'); }); test('getList returns paginated result', function (): void { @@ -1392,10 +1333,8 @@ test('update updates admin details', function (): void { 'signature' => '1345', ]; - $adminModel = \Tests\Helpers\admin(); - - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire')->atLeast()->once(); + $adminModel = \Tests\Helpers\admin(['id' => 5]); + [$eventDispatcher, $events] = staffAccountCrudEventDispatcher(); $logStub = $this->createStub(FOSSBilling\Logger::class); @@ -1404,7 +1343,7 @@ test('update updates admin details', function (): void { $serviceMock->shouldReceive('hasPermission')->atLeast()->once()->andReturn(true); $di = container(); - $di['events_manager'] = $eventsMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = $logStub; $di['em']->shouldReceive('persist')->atLeast()->once(); $di['em']->shouldReceive('flush')->atLeast()->once(); @@ -1414,6 +1353,10 @@ test('update updates admin details', function (): void { $result = $serviceMock->update($adminModel, $data); expect($result)->toBeTrue(); + expect(array_map(static fn (object $event): string => $event::class, $events->getArrayCopy())) + ->toBe([BeforeAdminStaffUpdateEvent::class, AfterAdminStaffUpdateEvent::class]); + expect($events[0]->adminId)->toBe(5); + expect($events[1]->adminId)->toBe(5); }); test('update rejects deactivating last active super administrator', function (): void { @@ -1424,46 +1367,47 @@ test('update rejects deactivating last active super administrator', function (): $groupMemberRepository->shouldReceive('adminBelongsToSystemGroup')->once()->with(3, AdminGroup::SYSTEM_SUPER_ADMIN)->andReturn(true); $groupMemberRepository->shouldReceive('countActiveMembersInSystemGroup')->once()->with(AdminGroup::SYSTEM_SUPER_ADMIN)->andReturn(1); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire')->atLeast()->once(); + [$eventDispatcher, $events] = staffAccountCrudEventDispatcher(); $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldReceive('hasPermission')->atLeast()->once()->andReturn(true); $di = container(); $di['em'] = staffEntityManager($groupRepository, $groupMemberRepository); - $di['events_manager'] = $eventsMock; + $di['event_dispatcher'] = $eventDispatcher; $di['loggedin_admin'] = staffHierarchyBypassAdmin(); $serviceMock->setDi($di); expect(fn () => $serviceMock->update($adminModel, ['status' => Admin::STATUS_INACTIVE])) ->toThrow(FOSSBilling\InformationException::class, 'Cannot remove the last active super administrator'); + expect($events->getArrayCopy())->toHaveCount(1); + expect($events[0])->toBeInstanceOf(BeforeAdminStaffUpdateEvent::class); }); test('update rejects deactivating own staff account', function (): void { $adminModel = \Tests\Helpers\admin(['id' => 10, 'status' => Admin::STATUS_ACTIVE]); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire')->atLeast()->once(); + [$eventDispatcher, $events] = staffAccountCrudEventDispatcher(); $serviceMock = Mockery::mock(Service::class)->makePartial(); $serviceMock->shouldReceive('hasPermission')->once()->andReturn(true); $di = container(); $di['em'] = staffEntityManager(Mockery::mock(AdminGroupRepository::class), Mockery::mock(AdminGroupMemberRepository::class)); - $di['events_manager'] = $eventsMock; + $di['event_dispatcher'] = $eventDispatcher; $di['loggedin_admin'] = staffRegularAdmin(); $serviceMock->setDi($di); expect(fn () => $serviceMock->update($adminModel, ['status' => Admin::STATUS_INACTIVE])) ->toThrow(FOSSBilling\InformationException::class, 'You cannot deactivate your own staff account'); + expect($events->getArrayCopy())->toHaveCount(1); + expect($events[0])->toBeInstanceOf(BeforeAdminStaffUpdateEvent::class); }); test('delete removes admin account', function (): void { $adminModel = \Tests\Helpers\admin(['id' => 5]); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire')->atLeast()->once(); + [$eventDispatcher, $events] = staffAccountCrudEventDispatcher(); $logStub = $this->createStub(FOSSBilling\Logger::class); @@ -1484,7 +1428,7 @@ test('delete removes admin account', function (): void { $di = container(); $di['em'] = staffEntityManager(Mockery::mock(AdminGroupRepository::class), $groupMemberRepository, null, $passwordResetRepository); - $di['events_manager'] = $eventsMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = $logStub; $di['loggedin_admin'] = staffHierarchyBypassAdmin(); @@ -1492,6 +1436,10 @@ test('delete removes admin account', function (): void { $result = $serviceMock->delete($adminModel); expect($result)->toBeTrue(); + expect(array_map(static fn (object $event): string => $event::class, $events->getArrayCopy())) + ->toBe([BeforeAdminStaffDeleteEvent::class, AfterAdminStaffDeleteEvent::class]); + expect($events[0]->adminId)->toBe(5); + expect($events[1]->adminId)->toBe(5); }); test('delete rejects removing last active super administrator', function (): void { @@ -1525,10 +1473,9 @@ test('delete rejects cron account', function (): void { test('changePassword updates admin password', function (): void { $plainTextPassword = 'password'; - $adminModel = \Tests\Helpers\admin(); + $adminModel = \Tests\Helpers\admin(['id' => 5]); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire')->atLeast()->once(); + [$eventDispatcher, $events] = staffAccountCrudEventDispatcher(); $logStub = $this->createStub(FOSSBilling\Logger::class); @@ -1543,7 +1490,7 @@ test('changePassword updates admin password', function (): void { $serviceMock->shouldReceive('hasPermission')->atLeast()->once()->andReturn(true); $di = container(); - $di['events_manager'] = $eventsMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = $logStub; $di['em']->shouldReceive('persist')->atLeast()->once(); $di['em']->shouldReceive('flush')->atLeast()->once(); @@ -1555,6 +1502,10 @@ test('changePassword updates admin password', function (): void { $result = $serviceMock->changePassword($adminModel, $plainTextPassword); expect($result)->toBeTrue(); + expect(array_map(static fn (object $event): string => $event::class, $events->getArrayCopy())) + ->toBe([BeforeAdminStaffPasswordChangeEvent::class, AfterAdminStaffPasswordChangeEvent::class]); + expect($events[0]->adminId)->toBe(5); + expect($events[1]->adminId)->toBe(5); }); test('create creates new admin account', function (): void { @@ -1564,14 +1515,15 @@ test('create creates new admin account', function (): void { 'status' => 'active', 'password' => '1345', 'group_id' => 2, + 'api_token' => 'token-must-not-be-exposed', + 'password_confirm' => 'confirmation-must-not-be-exposed', ]; $newId = 1; $group = new AdminGroup(); staffSetEntityId($group, 2); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire')->atLeast()->once(); + [$eventDispatcher, $events] = staffAccountCrudEventDispatcher(); $logStub = $this->createStub(FOSSBilling\Logger::class); @@ -1584,7 +1536,7 @@ test('create creates new admin account', function (): void { $serviceMock->shouldReceive('hasPermission')->atLeast()->once()->andReturn(true); $di = container(); - $di['events_manager'] = $eventsMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = $logStub; $di['em'] = staffEntityManager(Mockery::mock(AdminGroupRepository::class)->shouldReceive('findById')->once()->with(2)->andReturn($group)->getMock()); $di['loggedin_admin'] = staffHierarchyBypassAdmin(); @@ -1596,6 +1548,15 @@ test('create creates new admin account', function (): void { expect($result)->toBeInt(); expect($result)->toBe($newId); expect($di['em']->persisted[1])->toBeInstanceOf(AdminGroupMember::class); + expect(array_map(static fn (object $event): string => $event::class, $events->getArrayCopy())) + ->toBe([BeforeAdminStaffCreateEvent::class, AfterAdminStaffCreateEvent::class]); + expect($events[0]->input)->toBe([ + 'email' => 'test@example.com', + 'name' => 'testJohn', + 'status' => 'active', + 'group_id' => 2, + ]); + expect($events[1]->adminId)->toBe(1); }); test('create rejects missing initial group', function (): void { @@ -1627,8 +1588,7 @@ test('create throws exception for duplicate email', function (): void { $group = new AdminGroup(); staffSetEntityId($group, 2); - $eventsMock = Mockery::mock('\Box_EventManager'); - $eventsMock->shouldReceive('fire')->atLeast()->once(); + [$eventDispatcher, $events] = staffAccountCrudEventDispatcher(); $groupRepository = Mockery::mock(AdminGroupRepository::class)->shouldReceive('findById')->once()->with(2)->andReturn($group)->getMock(); $emMock = Mockery::mock(EntityManagerInterface::class); @@ -1658,7 +1618,7 @@ test('create throws exception for duplicate email', function (): void { $serviceMock->shouldReceive('hasPermission')->atLeast()->once()->andReturn(true); $di = container(); - $di['events_manager'] = $eventsMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = $logStub; $di['em'] = $emMock; $di['loggedin_admin'] = staffHierarchyBypassAdmin(); @@ -1668,6 +1628,8 @@ test('create throws exception for duplicate email', function (): void { expect(fn () => $serviceMock->create($data)) ->toThrow(FOSSBilling\Exception::class, "Staff member with email {$data['email']} is already registered."); + expect($events->getArrayCopy())->toHaveCount(1); + expect($events[0])->toBeInstanceOf(BeforeAdminStaffCreateEvent::class); }); test('createGroup creates new admin group', function (): void { diff --git a/src/modules/Support/Event/AfterTicketClosedEvent.php b/src/modules/Support/Event/AfterTicketClosedEvent.php new file mode 100644 index 000000000..aaaa8e278 --- /dev/null +++ b/src/modules/Support/Event/AfterTicketClosedEvent.php @@ -0,0 +1,21 @@ + $input */ + public function __construct( + public readonly array $input, + private ?string $status, + private ?string $subject, + private ?string $message, + ) { + } + + public function getStatus(): ?string + { + return $this->status; + } + + public function setStatus(?string $status): void + { + $this->status = $status; + } + + public function getSubject(): ?string + { + return $this->subject; + } + + public function setSubject(?string $subject): void + { + $this->subject = $subject; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setMessage(?string $message): void + { + $this->message = $message; + } +} diff --git a/src/modules/Support/Event/BeforeTicketCreateEvent.php b/src/modules/Support/Event/BeforeTicketCreateEvent.php new file mode 100644 index 000000000..f7c683e3e --- /dev/null +++ b/src/modules/Support/Event/BeforeTicketCreateEvent.php @@ -0,0 +1,24 @@ + $input */ + public function __construct( + public readonly TicketActorRole $actor, + public readonly int $clientId, + public readonly array $input, + ) { + } +} diff --git a/src/modules/Support/Event/TicketActorRole.php b/src/modules/Support/Event/TicketActorRole.php new file mode 100644 index 000000000..56e7281cc --- /dev/null +++ b/src/modules/Support/Event/TicketActorRole.php @@ -0,0 +1,17 @@ +getDi(); - $params = $event->getParameters(); + $di = $this->di ?? throw new \LogicException('The Support service dependency injection container has not been set.'); $supportService = $di['mod_service']('support'); $emailService = $di['mod_service']('email'); try { - $ticketObj = $supportService->getTicketById((int) $params['id']); + $ticketObj = $supportService->getTicketById($event->ticketId); $isGuestTicket = $ticketObj->isGuestTicket(); - $identity = $isGuestTicket ? null : $di['loggedin_client']; + $identity = match ($event->actor) { + TicketActorRole::ADMIN => $di['loggedin_admin'], + TicketActorRole::CLIENT => $di['loggedin_client'], + TicketActorRole::GUEST => null, + }; $ticketArr = $supportService->toApiArray($ticketObj, true, $identity); $email = []; @@ -165,51 +176,29 @@ class Service implements \FOSSBilling\InjectionAwareInterface } else { $email['to_client'] = $ticketObj->getClientId(); } - $email['code'] = 'mod_support_ticket_open'; + $email['code'] = $event->actor === TicketActorRole::ADMIN ? 'mod_support_ticket_staff_open' : 'mod_support_ticket_open'; $email['ticket'] = $ticketArr; $emailService->sendTemplate($email); } catch (\Exception $exc) { - $di['logger']->withChannel('email')->error('Failed to send ticket open email', ['exception' => $exc]); + $message = $event->actor === TicketActorRole::ADMIN ? 'Failed to send admin ticket open email' : 'Failed to send ticket open email'; + $di['logger']->withChannel('email')->error($message, ['exception' => $exc]); } } - public static function onAfterAdminOpenTicket(\Box_Event $event): void + #[AsEventListener] + public function notifyTicketClosed(AfterTicketClosedEvent $event): void { - $di = $event->getDi(); - $supportService = $di['mod_service']('support'); - $emailService = $di['mod_service']('email'); - $params = $event->getParameters(); - - try { - $ticketObj = $supportService->getTicketById((int) $params['id']); - $identity = $di['loggedin_admin']; - $ticketArr = $supportService->toApiArray($ticketObj, true, $identity); - - $email = []; - if ($ticketObj->isGuestTicket()) { - $email['to'] = $ticketObj->getAuthorEmail(); - $email['to_name'] = $ticketObj->getAuthorName(); - } else { - $email['to_client'] = $ticketObj->getClientId(); - } - $email['code'] = 'mod_support_ticket_staff_open'; - $email['ticket'] = $ticketArr; - $emailService->sendTemplate($email); - } catch (\Exception $exc) { - $di['logger']->withChannel('email')->error('Failed to send admin ticket open email', ['exception' => $exc]); + if ($event->actor !== TicketActorRole::ADMIN) { + return; } - } - public static function onAfterAdminCloseTicket(\Box_Event $event): void - { - $di = $event->getDi(); + $di = $this->di ?? throw new \LogicException('The Support service dependency injection container has not been set.'); $supportService = $di['mod_service']('support'); $emailService = $di['mod_service']('email'); - $params = $event->getParameters(); try { $identity = $di['loggedin_admin']; - $ticketObj = $supportService->getTicketById((int) $params['id']); + $ticketObj = $supportService->getTicketById($event->ticketId); $ticketArr = $supportService->toApiArray($ticketObj, true, $identity); $email = []; @@ -227,15 +216,19 @@ class Service implements \FOSSBilling\InjectionAwareInterface } } - public static function onAfterAdminReplyTicket(\Box_Event $event): void + #[AsEventListener] + public function notifyTicketReplied(AfterTicketRepliedEvent $event): void { - $di = $event->getDi(); + if ($event->actor !== TicketActorRole::ADMIN) { + return; + } + + $di = $this->di ?? throw new \LogicException('The Support service dependency injection container has not been set.'); $supportService = $di['mod_service']('support'); $emailService = $di['mod_service']('email'); - $params = $event->getParameters(); try { - $ticketObj = $supportService->getTicketById((int) $params['id']); + $ticketObj = $supportService->getTicketById($event->ticketId); $identity = $di['loggedin_admin']; $ticketArr = $supportService->toApiArray($ticketObj, true, $identity); @@ -326,11 +319,12 @@ class Service implements \FOSSBilling\InjectionAwareInterface $ticket->close(); $this->di['em']->flush(); - if ($identity instanceof \Box\Mod\Staff\Entity\Admin) { - $this->di['events_manager']->fire(['event' => 'onAfterAdminCloseTicket', 'params' => ['id' => $ticket->getId()]]); - } else { - $this->di['events_manager']->fire(['event' => 'onAfterClientCloseTicket', 'params' => ['id' => $ticket->getId()]]); - } + $actor = match (true) { + $identity instanceof \Box\Mod\Staff\Entity\Admin => TicketActorRole::ADMIN, + $identity instanceof Client => TicketActorRole::CLIENT, + default => TicketActorRole::GUEST, + }; + $this->di['event_dispatcher']->dispatch(new AfterTicketClosedEvent((int) $ticket->getId(), $actor)); $this->di['logger']->info('Closed ticket "{ticket_id}"', ['ticket_id' => $ticket->getId()]); @@ -985,11 +979,12 @@ class Service implements \FOSSBilling\InjectionAwareInterface $ticket->setUpdatedAt(new \DateTime()); $em->flush(); - if ($identity instanceof \Box\Mod\Staff\Entity\Admin) { - $this->di['events_manager']->fire(['event' => 'onAfterAdminReplyTicket', 'params' => ['id' => $ticket->getId()]]); - } else { - $this->di['events_manager']->fire(['event' => 'onAfterClientReplyTicket', 'params' => ['id' => $ticket->getId()]]); - } + $actor = match (true) { + $identity instanceof \Box\Mod\Staff\Entity\Admin => TicketActorRole::ADMIN, + $identity instanceof Client => TicketActorRole::CLIENT, + default => TicketActorRole::GUEST, + }; + $this->di['event_dispatcher']->dispatch(new AfterTicketRepliedEvent((int) $ticket->getId(), $actor)); $this->di['logger']->info('Replied to ticket "{ticket_id}"', ['ticket_id' => $ticket->getId()]); @@ -1000,7 +995,7 @@ class Service implements \FOSSBilling\InjectionAwareInterface { $status = $data['status'] ?? SupportTicket::STATUS_ONHOLD; - $this->di['events_manager']->fire(['event' => 'onBeforeAdminOpenTicket', 'params' => $data]); + $this->di['event_dispatcher']->dispatch(new BeforeTicketCreateEvent(TicketActorRole::ADMIN, $clientId, $data)); $em = $this->di['em']; $ticket = new SupportTicket(); @@ -1019,7 +1014,7 @@ class Service implements \FOSSBilling\InjectionAwareInterface $em->persist($msg); $em->flush(); - $this->di['events_manager']->fire(['event' => 'onAfterAdminOpenTicket', 'params' => ['id' => $ticket->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterTicketOpenedEvent((int) $ticket->getId(), TicketActorRole::ADMIN)); $this->di['logger']->info('Admin opened new ticket "{ticket_id}"', ['ticket_id' => $ticket->getId()]); @@ -1040,17 +1035,13 @@ class Service implements \FOSSBilling\InjectionAwareInterface $event_params = $data; $event_params['author_role'] = 'guest'; $event_params['ip'] = $this->di['request']->getClientIp(); - $altered = $this->di['events_manager']->fire(['event' => 'onBeforeClientOpenTicket', 'params' => $event_params]); - $status = 'open'; - $subject = $data['subject'] ?? null; - $message = $data['content'] ?? null; - - if (is_array($altered)) { - $status = $altered['status'] ?? null; - $subject = $altered['subject'] ?? null; - $message = $altered['content'] ?? $altered['message'] ?? null; - } + $ticketEvent = $this->di['event_dispatcher']->dispatch(new BeforeGuestTicketCreateEvent( + input: $event_params, + status: 'open', + subject: $data['subject'] ?? null, + message: $data['content'] ?? null, + )); $helpdesk = isset($data['support_helpdesk_id']) ? $this->getHelpdeskRepository()->find((int) $data['support_helpdesk_id']) @@ -1066,19 +1057,19 @@ class Service implements \FOSSBilling\InjectionAwareInterface $ticket->setSupportHelpdesk($helpdesk); $ticket->setAuthorName($data['name']); $ticket->setAuthorEmail($data['email']); - $ticket->setSubject($subject); - $ticket->setStatus($status); + $ticket->setSubject($ticketEvent->getSubject()); + $ticket->setStatus($ticketEvent->getStatus()); $em->persist($ticket); $em->flush(); $msg = new SupportTicketMessage(); $msg->setSupportTicket($ticket); - $msg->setContent($message); + $msg->setContent($ticketEvent->getMessage()); $msg->setIp($this->di['request']->getClientIp()); $em->persist($msg); $em->flush(); - $this->di['events_manager']->fire(['event' => 'onAfterClientOpenTicket', 'params' => ['id' => $ticket->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterTicketOpenedEvent((int) $ticket->getId(), TicketActorRole::GUEST)); $this->di['logger']->info('"{author_email}" opened guest ticket "{ticket_id}"', ['author_email' => $ticket->getAuthorEmail(), 'ticket_id' => $ticket->getId()]); @@ -1173,7 +1164,7 @@ class Service implements \FOSSBilling\InjectionAwareInterface $event_params = $data; $event_params['author_role'] = 'client'; $event_params['client_id'] = $client->getId(); - $this->di['events_manager']->fire(['event' => 'onBeforeClientOpenTicket', 'params' => $event_params]); + $this->di['event_dispatcher']->dispatch(new BeforeTicketCreateEvent(TicketActorRole::CLIENT, (int) $client->getId(), $event_params)); $ticket = new SupportTicket(); $ticket->setClientId((int) $client->getId()); @@ -1192,7 +1183,7 @@ class Service implements \FOSSBilling\InjectionAwareInterface $this->messageCreateForTicket($ticket, $client, $data['content']); - $this->di['events_manager']->fire(['event' => 'onAfterClientOpenTicket', 'params' => ['id' => $ticket->getId()]]); + $this->di['event_dispatcher']->dispatch(new AfterTicketOpenedEvent((int) $ticket->getId(), TicketActorRole::CLIENT)); if ( isset($config['autorespond_enable']) diff --git a/src/modules/Support/tests/Unit/ServiceTest.php b/src/modules/Support/tests/Unit/ServiceTest.php index 5a2ce7316..24c394dc8 100644 --- a/src/modules/Support/tests/Unit/ServiceTest.php +++ b/src/modules/Support/tests/Unit/ServiceTest.php @@ -23,6 +23,12 @@ use Box\Mod\Support\Entity\SupportTicket; use Box\Mod\Support\Entity\SupportTicketMessage; use Box\Mod\Support\Entity\SupportTicketMessageHistory; use Box\Mod\Support\Entity\SupportTicketNote; +use Box\Mod\Support\Event\AfterTicketClosedEvent; +use Box\Mod\Support\Event\AfterTicketOpenedEvent; +use Box\Mod\Support\Event\AfterTicketRepliedEvent; +use Box\Mod\Support\Event\BeforeGuestTicketCreateEvent; +use Box\Mod\Support\Event\BeforeTicketCreateEvent; +use Box\Mod\Support\Event\TicketActorRole; use Box\Mod\Support\Repository\CannedResponseCategoryRepository; use Box\Mod\Support\Repository\CannedResponseRepository; use Box\Mod\Support\Repository\HelpdeskRepository; @@ -34,6 +40,7 @@ use Box\Mod\Support\Repository\SupportTicketNoteRepository; use Box\Mod\Support\Repository\SupportTicketRepository; use Box\Mod\Support\Service; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\EventDispatcher\EventDispatcher; use function Tests\Helpers\container; use function Tests\Helpers\createEntity; @@ -179,7 +186,7 @@ test('gets and sets dependency injection container', function (): void { * Event Handler Tests */ -test('handles after client open ticket event', function (): void { +test('notifies the client after a typed ticket opened event', function (): void { $service = new Service(); $toApiArrayReturn = [ 'client' => [ @@ -190,7 +197,8 @@ test('handles after client open ticket event', function (): void { $supportTicketModel = new SupportTicket(); setEntityId($supportTicketModel, 1); $serviceMock->shouldReceive('getTicketById') - ->atLeast()->once() + ->once() + ->with(1) ->andReturn($supportTicketModel); $serviceMock->shouldReceive('toApiArray') ->atLeast()->once() @@ -214,19 +222,11 @@ test('handles after client open ticket event', function (): void { $di['loggedin_client'] = createEntity(Client::class); $serviceMock->setDi($di); - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - - $result = $serviceMock->onAfterClientOpenTicket($eventMock); + $result = $serviceMock->notifyTicketOpened(new AfterTicketOpenedEvent(1, TicketActorRole::CLIENT)); expect($result)->toBeNull(); }); -test('handles after admin open ticket event', function (): void { +test('notifies staff after an admin opens a ticket', function (): void { $service = new Service(); $toApiArrayReturn = [ 'client' => [ @@ -237,7 +237,8 @@ test('handles after admin open ticket event', function (): void { $supportTicketModel = new SupportTicket(); setEntityId($supportTicketModel, 1); $serviceMock->shouldReceive('getTicketById') - ->atLeast()->once() + ->once() + ->with(1) ->andReturn($supportTicketModel); $serviceMock->shouldReceive('toApiArray') ->atLeast()->once() @@ -261,19 +262,11 @@ test('handles after admin open ticket event', function (): void { $di['loggedin_admin'] = \Tests\Helpers\admin(); $serviceMock->setDi($di); - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - - $result = $serviceMock->onAfterAdminOpenTicket($eventMock); + $result = $serviceMock->notifyTicketOpened(new AfterTicketOpenedEvent(1, TicketActorRole::ADMIN)); expect($result)->toBeNull(); }); -test('handles after admin close ticket event', function (): void { +test('notifies staff after an admin closes a ticket', function (): void { $service = new Service(); $toApiArrayReturn = [ 'client' => [ @@ -284,7 +277,8 @@ test('handles after admin close ticket event', function (): void { $supportTicketModel = new SupportTicket(); setEntityId($supportTicketModel, 1); $serviceMock->shouldReceive('getTicketById') - ->atLeast()->once() + ->once() + ->with(1) ->andReturn($supportTicketModel); $serviceMock->shouldReceive('toApiArray') ->atLeast()->once() @@ -308,19 +302,11 @@ test('handles after admin close ticket event', function (): void { $di['loggedin_admin'] = \Tests\Helpers\admin(); $serviceMock->setDi($di); - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - - $result = $serviceMock->onAfterAdminCloseTicket($eventMock); + $result = $serviceMock->notifyTicketClosed(new AfterTicketClosedEvent(1, TicketActorRole::ADMIN)); expect($result)->toBeNull(); }); -test('handles after admin reply ticket event', function (): void { +test('notifies staff after an admin replies to a ticket', function (): void { $service = new Service(); $toApiArrayReturn = [ 'client' => [ @@ -331,7 +317,8 @@ test('handles after admin reply ticket event', function (): void { $supportTicketModel = new SupportTicket(); setEntityId($supportTicketModel, 1); $serviceMock->shouldReceive('getTicketById') - ->atLeast()->once() + ->once() + ->with(1) ->andReturn($supportTicketModel); $serviceMock->shouldReceive('toApiArray') ->atLeast()->once() @@ -355,19 +342,11 @@ test('handles after admin reply ticket event', function (): void { $di['loggedin_admin'] = \Tests\Helpers\admin(); $serviceMock->setDi($di); - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - - $result = $serviceMock->onAfterAdminReplyTicket($eventMock); + $result = $serviceMock->notifyTicketReplied(new AfterTicketRepliedEvent(1, TicketActorRole::ADMIN)); expect($result)->toBeNull(); }); -test('handles guest ticket with regular client open event', function (): void { +test('notifies guests after a typed ticket opened event', function (): void { $service = new Service(); $toApiArrayReturn = [ 'author_email' => 'email@example.com', @@ -381,7 +360,8 @@ test('handles guest ticket with regular client open event', function (): void { $supportPTicketModel->setAuthorEmail('email@example.com'); $supportPTicketModel->setAuthorName('Name'); $serviceMock->shouldReceive('getTicketById') - ->atLeast()->once() + ->once() + ->with(1) ->andReturn($supportPTicketModel); $serviceMock->shouldReceive('toApiArray') ->atLeast()->once() @@ -407,15 +387,7 @@ test('handles guest ticket with regular client open event', function (): void { }; $serviceMock->setDi($di); - $eventMock = Mockery::mock('\Box_Event'); - $eventMock->shouldReceive('getDi') - ->atLeast()->once() - ->andReturn($di); - $eventMock->shouldReceive('getParameters') - ->atLeast()->once() - ->andReturn(['id' => random_int(1, 100)]); - - $result = $serviceMock->onAfterClientOpenTicket($eventMock); + $result = $serviceMock->notifyTicketOpened(new AfterTicketOpenedEvent(1, TicketActorRole::GUEST)); expect($result)->toBeNull(); }); @@ -637,21 +609,29 @@ test('checks if task already exists returns false', function (): void { dataset('closeTicketIdentities', [ [\Tests\Helpers\admin()], [createEntity(Client::class)], + [new FOSSBilling\Identity\Guest()], ]); -test('closes a ticket', function ($identity): void { +test('closes a ticket and dispatches its typed event after flush', function ($identity): void { $service = new Service(); $emMock = Mockery::mock(EntityManagerInterface::class); supportWireKbRepositories($emMock); - $emMock->shouldReceive('flush')->atLeast()->once(); + $steps = []; + $emMock->shouldReceive('flush')->once()->andReturnUsing(function () use (&$steps): void { + $steps[] = 'flush'; + }); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire'); + $events = []; + $dispatcher = new EventDispatcher(); + $dispatcher->addListener(AfterTicketClosedEvent::class, function (AfterTicketClosedEvent $event) use (&$events, &$steps): void { + $events[] = $event; + $steps[] = 'event'; + }); $di = container(); $di['em'] = $emMock; $di['logger'] = new Tests\Helpers\TestLogger(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $dispatcher; $service->setDi($di); $ticket = new SupportTicket(); @@ -660,6 +640,14 @@ test('closes a ticket', function ($identity): void { $result = $service->closeTicket($ticket, $identity); expect($result)->toBeTrue(); expect($ticket->getStatus())->toBe(SupportTicket::STATUS_CLOSED); + expect($events)->toHaveCount(1); + expect($events[0]->ticketId)->toBe(1); + expect($events[0]->actor)->toBe(match (true) { + $identity instanceof Box\Mod\Staff\Entity\Admin => TicketActorRole::ADMIN, + $identity instanceof Client => TicketActorRole::CLIENT, + default => TicketActorRole::GUEST, + }); + expect($steps)->toBe(['flush', 'event']); })->with('closeTicketIdentities'); test('auto closes a ticket', function (): void { @@ -1737,14 +1725,12 @@ test('public close ticket', function (Box\Mod\Staff\Entity\Admin|FOSSBilling\Ide supportWireKbRepositories($emMock); $emMock->shouldReceive('flush')->atLeast()->once(); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire') - ->atLeast()->once(); + $dispatcher = new EventDispatcher(); $di = container(); $di['em'] = $emMock; $di['logger'] = new Tests\Helpers\TestLogger(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $dispatcher; $service->setDi($di); $ticket = new SupportTicket(); @@ -1775,28 +1761,35 @@ test('guest ticket reply', function (): void { $emMock = Mockery::mock(EntityManagerInterface::class); supportWireKbRepositories($emMock); + $steps = []; $emMock->shouldReceive('persist')->atLeast()->once() - ->andReturnUsing(function ($entity): void { + ->andReturnUsing(function ($entity) use (&$steps): void { + $steps[] = 'persist'; if ($entity->getId() === null) { \Tests\Helpers\setEntityId($entity, 1); } }); - $emMock->shouldReceive('flush')->atLeast()->once(); + $emMock->shouldReceive('flush')->atLeast()->once()->andReturnUsing(function () use (&$steps): void { + $steps[] = 'flush'; + }); $requestMock = Mockery::mock(FOSSBilling\Request::class); $requestMock->shouldReceive('getClientIp') ->atLeast()->once() ->andReturn('127.0.0.1'); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire') - ->atLeast()->once(); + $events = []; + $dispatcher = new EventDispatcher(); + $dispatcher->addListener(AfterTicketRepliedEvent::class, function (AfterTicketRepliedEvent $event) use (&$events, &$steps): void { + $events[] = $event; + $steps[] = 'replied'; + }); $di = container(); $di['em'] = $emMock; $di['logger'] = new Tests\Helpers\TestLogger(); $di['request'] = $requestMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $dispatcher; $service->setDi($di); $ticket = new SupportTicket(); @@ -1805,6 +1798,10 @@ test('guest ticket reply', function (): void { $result = $service->ticketReply($ticket, new FOSSBilling\Identity\Guest(), 'Content'); expect($result)->toBeInt(); + expect($events)->toHaveCount(1); + expect($events[0]->ticketId)->toBe(1); + expect($events[0]->actor)->toBe(TicketActorRole::GUEST); + expect($steps)->toBe(['persist', 'flush', 'flush', 'replied']); }); /* @@ -1958,17 +1955,24 @@ test('ticket reply', function (Box\Mod\Staff\Entity\Admin|Client $identity): voi $randId = 1; $emMock = Mockery::mock(EntityManagerInterface::class); supportWireKbRepositories($emMock); + $steps = []; $emMock->shouldReceive('persist')->atLeast()->once() - ->andReturnUsing(function ($entity): void { + ->andReturnUsing(function ($entity) use (&$steps): void { + $steps[] = 'persist'; if ($entity->getId() === null) { \Tests\Helpers\setEntityId($entity, 1); } }); - $emMock->shouldReceive('flush')->atLeast()->once(); + $emMock->shouldReceive('flush')->atLeast()->once()->andReturnUsing(function () use (&$steps): void { + $steps[] = 'flush'; + }); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire') - ->atLeast()->once(); + $events = []; + $dispatcher = new EventDispatcher(); + $dispatcher->addListener(AfterTicketRepliedEvent::class, function (AfterTicketRepliedEvent $event) use (&$events, &$steps): void { + $events[] = $event; + $steps[] = 'replied'; + }); $requestMock = Mockery::mock(FOSSBilling\Request::class); $requestMock->shouldReceive('getClientIp') @@ -1979,7 +1983,7 @@ test('ticket reply', function (Box\Mod\Staff\Entity\Admin|Client $identity): voi $di['em'] = $emMock; $di['logger'] = new Tests\Helpers\TestLogger(); $di['request'] = $requestMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $dispatcher; $service->setDi($di); $ticket = new SupportTicket(); @@ -1988,6 +1992,10 @@ test('ticket reply', function (Box\Mod\Staff\Entity\Admin|Client $identity): voi $result = $service->ticketReply($ticket, $identity, 'Content'); expect($result)->toBeInt(); expect($result)->toEqual($randId); + expect($events)->toHaveCount(1); + expect($events[0]->ticketId)->toBe(1); + expect($events[0]->actor)->toBe($identity instanceof Box\Mod\Staff\Entity\Admin ? TicketActorRole::ADMIN : TicketActorRole::CLIENT); + expect($steps)->toBe(['persist', 'flush', 'flush', 'replied']); })->with('ticketReplyProvider'); test('ticket create for admin', function (): void { @@ -1998,17 +2006,28 @@ test('ticket create for admin', function (): void { $randId = 1; $emMock = Mockery::mock(EntityManagerInterface::class); supportWireKbRepositories($emMock); + $steps = []; $emMock->shouldReceive('persist')->atLeast()->once() - ->andReturnUsing(function ($entity): void { + ->andReturnUsing(function ($entity) use (&$steps): void { + $steps[] = 'persist'; if ($entity->getId() === null) { \Tests\Helpers\setEntityId($entity, 1); } }); - $emMock->shouldReceive('flush')->atLeast()->once(); + $emMock->shouldReceive('flush')->atLeast()->once()->andReturnUsing(function () use (&$steps): void { + $steps[] = 'flush'; + }); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire') - ->atLeast()->once(); + $dispatcher = new EventDispatcher(); + $events = []; + $dispatcher->addListener(BeforeTicketCreateEvent::class, function (BeforeTicketCreateEvent $event) use (&$events, &$steps): void { + $events[] = $event; + $steps[] = 'before'; + }); + $dispatcher->addListener(AfterTicketOpenedEvent::class, function (AfterTicketOpenedEvent $event) use (&$events, &$steps): void { + $events[] = $event; + $steps[] = 'opened'; + }); $requestMock = Mockery::mock(FOSSBilling\Request::class); $requestMock->shouldReceive('getClientIp') @@ -2019,7 +2038,7 @@ test('ticket create for admin', function (): void { $di['em'] = $emMock; $di['logger'] = new Tests\Helpers\TestLogger(); $di['request'] = $requestMock; - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $dispatcher; $service->setDi($di); $helpdesk = helpdeskFixture(); @@ -2034,6 +2053,15 @@ test('ticket create for admin', function (): void { $result = $service->ticketCreateForAdmin(1, $helpdesk, $data, $admin); expect($result)->toBeInt(); expect($result)->toEqual($randId); + expect($events)->toHaveCount(2); + expect($events[0])->toBeInstanceOf(BeforeTicketCreateEvent::class); + expect($events[0]->actor)->toBe(TicketActorRole::ADMIN); + expect($events[0]->clientId)->toBe(1); + expect($events[0]->input)->toBe($data); + expect($events[1])->toBeInstanceOf(AfterTicketOpenedEvent::class); + expect($events[1]->ticketId)->toBe(1); + expect($events[1]->actor)->toBe(TicketActorRole::ADMIN); + expect($steps)->toBe(['before', 'persist', 'flush', 'persist', 'flush', 'opened']); }); test('ticket create for client', function (): void { @@ -2044,22 +2072,33 @@ test('ticket create for client', function (): void { $randId = 1; $emMock = Mockery::mock(EntityManagerInterface::class); supportWireKbRepositories($emMock); + $steps = []; $emMock->shouldReceive('persist')->atLeast()->once() - ->andReturnUsing(function ($entity): void { + ->andReturnUsing(function ($entity) use (&$steps): void { + $steps[] = 'persist'; if ($entity->getId() === null) { \Tests\Helpers\setEntityId($entity, 1); } }); - $emMock->shouldReceive('flush')->atLeast()->once(); + $emMock->shouldReceive('flush')->atLeast()->once()->andReturnUsing(function () use (&$steps): void { + $steps[] = 'flush'; + }); $cannedRepoMock = Mockery::mock(CannedResponseRepository::class); $cannedRepoMock->shouldReceive('find') ->atLeast()->once() ->andReturn(supportCannedResponseFixture()); supportWireKbRepositories($emMock, cannedRepo: $cannedRepoMock); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire') - ->atLeast()->once(); + $dispatcher = new EventDispatcher(); + $events = []; + $dispatcher->addListener(BeforeTicketCreateEvent::class, function (BeforeTicketCreateEvent $event) use (&$events, &$steps): void { + $events[] = $event; + $steps[] = 'before'; + }); + $dispatcher->addListener(AfterTicketOpenedEvent::class, function (AfterTicketOpenedEvent $event) use (&$events, &$steps): void { + $events[] = $event; + $steps[] = 'opened'; + }); $config = [ 'autorespond_enable' => 1, @@ -2085,7 +2124,7 @@ test('ticket create for client', function (): void { $di = container(); $di['em'] = $emMock; $di['logger'] = new Tests\Helpers\TestLogger(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $dispatcher; $di['mod'] = $di->protect(fn () => $supportModMock); $di['mod_service'] = $di->protect(fn () => $staffServiceMock); @@ -2105,6 +2144,97 @@ test('ticket create for client', function (): void { $result = $serviceMock->ticketCreateForClient($client, $helpdesk, $data); expect($result)->toBeInt(); expect($result)->toEqual($randId); + expect($events)->toHaveCount(2); + expect($events[0])->toBeInstanceOf(BeforeTicketCreateEvent::class); + expect($events[0]->actor)->toBe(TicketActorRole::CLIENT); + expect($events[0]->clientId)->toBe(1); + expect($events[0]->input)->toBe([...$data, 'author_role' => 'client', 'client_id' => 1]); + expect($events[1])->toBeInstanceOf(AfterTicketOpenedEvent::class); + expect($events[1]->ticketId)->toBe(1); + expect($events[1]->actor)->toBe(TicketActorRole::CLIENT); + expect($steps)->toBe(['before', 'persist', 'flush', 'opened']); +}); + +test('guest ticket creation uses values from the typed before event', function (): void { + $service = new Service(); + $helpdesk = helpdeskFixture(); + $helpdeskRepo = Mockery::mock(HelpdeskRepository::class); + $helpdeskRepo->shouldReceive('getDefault')->once()->andReturn($helpdesk); + + $emMock = Mockery::mock(EntityManagerInterface::class); + supportWireKbRepositories($emMock, helpdeskRepo: $helpdeskRepo); + $steps = []; + $persistedTicket = null; + $persistedMessage = null; + $emMock->shouldReceive('persist')->twice()->andReturnUsing(function ($entity) use (&$persistedTicket, &$persistedMessage, &$steps): void { + if ($entity instanceof SupportTicket) { + $persistedTicket = $entity; + setEntityId($entity, 31); + $steps[] = 'persist-ticket'; + } else { + $persistedMessage = $entity; + setEntityId($entity, 32); + $steps[] = 'persist-message'; + } + }); + $emMock->shouldReceive('flush')->twice()->andReturnUsing(function () use (&$steps): void { + $steps[] = 'flush'; + }); + + $requestMock = Mockery::mock(FOSSBilling\Request::class); + $requestMock->shouldReceive('getClientIp')->twice()->andReturn('198.51.100.20'); + $toolsMock = Mockery::mock(FOSSBilling\Tools::class); + $toolsMock->shouldReceive('validateAndSanitizeEmail')->once() + ->with('guest@example.com') + ->andReturn('guest@example.com'); + $extensionService = Mockery::mock(); + $extensionService->shouldReceive('getConfig')->once()->with('mod_support')->andReturn([]); + + $dispatcher = new EventDispatcher(); + $beforeEvent = null; + $afterEvent = null; + $dispatcher->addListener(BeforeGuestTicketCreateEvent::class, function (BeforeGuestTicketCreateEvent $event) use (&$beforeEvent, &$steps): void { + $beforeEvent = $event; + $steps[] = 'before'; + $event->setStatus(SupportTicket::STATUS_ONHOLD); + $event->setSubject('Subject changed by listener'); + $event->setMessage('Message changed by listener'); + }); + $dispatcher->addListener(AfterTicketOpenedEvent::class, function (AfterTicketOpenedEvent $event) use (&$afterEvent, &$steps): void { + $afterEvent = $event; + $steps[] = 'opened'; + }); + + $di = container(); + $di['em'] = $emMock; + $di['event_dispatcher'] = $dispatcher; + $di['request'] = $requestMock; + $di['tools'] = $toolsMock; + $di['logger'] = new Tests\Helpers\TestLogger(); + $di['mod_service'] = $di->protect(fn (string $name) => $extensionService); + $service->setDi($di); + + $data = [ + 'name' => 'Guest', + 'email' => 'guest@example.com', + 'subject' => 'Original subject', + 'content' => 'Original message', + ]; + $accessHash = $service->ticketCreateForGuest($data); + + expect($accessHash)->toBeString()->not->toBeEmpty(); + expect($beforeEvent)->toBeInstanceOf(BeforeGuestTicketCreateEvent::class); + expect($beforeEvent->input)->toBe([...$data, 'author_role' => 'guest', 'ip' => '198.51.100.20']); + expect($persistedTicket)->toBeInstanceOf(SupportTicket::class); + expect($persistedTicket->getSubject())->toBe('Subject changed by listener'); + expect($persistedTicket->getStatus())->toBe(SupportTicket::STATUS_ONHOLD); + expect($persistedTicket->getAccessHash())->toBe($accessHash); + expect($persistedMessage)->toBeInstanceOf(SupportTicketMessage::class); + expect($persistedMessage->getContent())->toBe('Message changed by listener'); + expect($afterEvent)->toBeInstanceOf(AfterTicketOpenedEvent::class); + expect($afterEvent->ticketId)->toBe(31); + expect($afterEvent->actor)->toBe(TicketActorRole::GUEST); + expect($steps)->toBe(['before', 'persist-ticket', 'flush', 'persist-message', 'flush', 'opened']); }); test('ticket create for client task already exists exception', function (): void { diff --git a/src/modules/System/Api/Admin.php b/src/modules/System/Api/Admin.php index fdd388839..f35a99578 100644 --- a/src/modules/System/Api/Admin.php +++ b/src/modules/System/Api/Admin.php @@ -15,6 +15,10 @@ declare(strict_types=1); namespace Box\Mod\System\Api; +use Box\Mod\System\Event\AfterAdminManualUpdateEvent; +use Box\Mod\System\Event\AfterAdminUpdateCoreEvent; +use Box\Mod\System\Event\BeforeAdminManualUpdateEvent; +use Box\Mod\System\Event\BeforeAdminUpdateCoreEvent; use FOSSBilling\Cache\CacheFactory; use FOSSBilling\Config; use FOSSBilling\Doctrine\EntityManagerFactory; @@ -341,7 +345,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi } $new_version = $updater->getLatestVersion(); - $this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminUpdateCore']); + $this->getDi()['event_dispatcher']->dispatch(new BeforeAdminUpdateCoreEvent()); $updater->performUpdate(); $this->getDi()['logger']->info('Installed FOSSBilling update files from {previous_version} to {new_version}. Update finalization is pending.', ['previous_version' => \FOSSBilling\Version::VERSION, 'new_version' => $new_version]); @@ -365,7 +369,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi } $this->getDi()['update_finalization']->finalizeUpdate(); - $this->getDi()['events_manager']->fire(['event' => 'onAfterAdminUpdateCore']); + $this->getDi()['event_dispatcher']->dispatch(new AfterAdminUpdateCoreEvent()); $this->getDi()['logger']->info('Finalized FOSSBilling update to {version}.', ['version' => \FOSSBilling\Version::VERSION]); return true; @@ -423,9 +427,9 @@ class Admin extends \FOSSBilling\Api\AbstractApi } $updater = $this->getDi()['updater']; - $this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminManualUpdate']); + $this->getDi()['event_dispatcher']->dispatch(new BeforeAdminManualUpdateEvent()); $updater->performManualUpdate(); - $this->getDi()['events_manager']->fire(['event' => 'onAfterAdminManualUpdate']); + $this->getDi()['event_dispatcher']->dispatch(new AfterAdminManualUpdateEvent()); $this->getDi()['logger']->info('Updated FOSSBilling - applied patches and updated configuration file.'); return true; diff --git a/src/modules/System/Event/AfterAdminManualUpdateEvent.php b/src/modules/System/Event/AfterAdminManualUpdateEvent.php new file mode 100644 index 000000000..62da92746 --- /dev/null +++ b/src/modules/System/Event/AfterAdminManualUpdateEvent.php @@ -0,0 +1,17 @@ + $parameterNames */ + public function __construct( + public readonly array $parameterNames, + ) { + } +} diff --git a/src/modules/System/Event/BeforeAdminUpdateCoreEvent.php b/src/modules/System/Event/BeforeAdminUpdateCoreEvent.php new file mode 100644 index 000000000..3e3fd414c --- /dev/null +++ b/src/modules/System/Event/BeforeAdminUpdateCoreEvent.php @@ -0,0 +1,17 @@ +di['events_manager']->fire(['event' => 'onBeforeAdminSettingsUpdate', 'params' => $data]); + $parameterNames = array_map(static fn (int|string $key): string => (string) $key, array_keys($data)); + $this->di['event_dispatcher']->dispatch(new BeforeAdminSettingsUpdateEvent($parameterNames)); foreach ($data as $key => $val) { if (!$this->canUpdateParam($key)) { @@ -297,7 +302,7 @@ class Service // Flush the batch once; a unique-constraint collision surfaces to the caller. $this->di['em']->flush(); - $this->di['events_manager']->fire(['event' => 'onAfterAdminSettingsUpdate']); + $this->di['event_dispatcher']->dispatch(new AfterAdminSettingsUpdateEvent()); $this->di['logger']->info('Updated system general settings'); @@ -741,23 +746,23 @@ class Service return true; } - public static function onBeforeAdminCronRun(\Box_Event $event): void + #[AsEventListener] + public function refreshGeoIpAndPruneCache(BeforeAdminCronRunEvent $event): void { - $di = $event->getDi(); /** @var Reader $geoipReader */ $geoipReader = (new \ReflectionClass(Reader::class))->newInstanceWithoutConstructor(); - $geoipReader->setDi($di); + $geoipReader->setDi($this->di); $geoipReader->updateDefaultDatabases(); try { // Prune the cache. Only filesystem-backed pools support this; Redis/Memcached // expire entries on their own and don't implement PruneableInterface. - $cache = $di['cache']; + $cache = $this->di['cache']; if ($cache instanceof \Symfony\Component\Cache\PruneableInterface && $cache->prune()) { - $di['logger']->withChannel('cron')->info('Pruned the filesystem cache'); + $this->di['logger']->withChannel('cron')->info('Pruned the filesystem cache'); } } catch (\Exception $e) { - $di['logger']->error($e->getMessage()); + $this->di['logger']->error($e->getMessage()); } } diff --git a/src/modules/System/tests/Unit/Api/AdminTest.php b/src/modules/System/tests/Unit/Api/AdminTest.php index cc250e6af..56bc7df1e 100644 --- a/src/modules/System/tests/Unit/Api/AdminTest.php +++ b/src/modules/System/tests/Unit/Api/AdminTest.php @@ -10,6 +10,10 @@ declare(strict_types=1); +use Box\Mod\System\Event\AfterAdminManualUpdateEvent; +use Box\Mod\System\Event\AfterAdminUpdateCoreEvent; +use Box\Mod\System\Event\BeforeAdminManualUpdateEvent; +use Box\Mod\System\Event\BeforeAdminUpdateCoreEvent; use FOSSBilling\Config; use function Tests\Helpers\container; @@ -55,6 +59,146 @@ test('update params', function (): void { expect($result)->toBeTrue(); }); +test('update core dispatches its before event before the updater runs', function (): void { + $api = apiEndpoint(new Box\Mod\System\Api\Admin()); + $admin = Tests\Helpers\admin(['id' => 1]); + $api->setIdentity($admin); + + $sequence = []; + $eventDispatcher = new class($sequence) { + public array $events = []; + + public function __construct(private array &$sequence) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + $this->sequence[] = 'before-event'; + + return $event; + } + }; + + $updater = Mockery::mock(); + $updater->shouldReceive('getUpdateBranch')->once()->andReturn('stable'); + $updater->shouldReceive('isUpdateAvailable')->once()->andReturn(true); + $updater->shouldReceive('getLatestVersion')->once()->andReturn('next-version'); + $updater->shouldReceive('performUpdate')->once()->andReturnUsing(static function () use (&$sequence): void { + $sequence[] = 'perform-update'; + }); + + $staffService = Mockery::mock(Box\Mod\Staff\Service::class); + $staffService->shouldReceive('checkPermissionsAndThrowException') + ->once() + ->with('system', 'system_update', null, $admin); + + $di = container(); + $di['event_dispatcher'] = $eventDispatcher; + $di['updater'] = $updater; + $di['logger'] = new Tests\Helpers\TestLogger(); + $di['mod_service'] = $di->protect(fn (string $serviceName): mixed => $serviceName === 'Staff' ? $staffService : false); + $api->setDi($di); + + expect($api->update_core([]))->toBeTrue() + ->and($sequence)->toBe(['before-event', 'perform-update']) + ->and($eventDispatcher->events)->toHaveCount(1) + ->and($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminUpdateCoreEvent::class); +}); + +test('finalize update dispatches its after event after finalization', function (): void { + $api = apiEndpoint(new Box\Mod\System\Api\Admin()); + $admin = Tests\Helpers\admin(['id' => 1]); + $api->setIdentity($admin); + + $sequence = []; + $eventDispatcher = new class($sequence) { + public array $events = []; + + public function __construct(private array &$sequence) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + $this->sequence[] = 'after-event'; + + return $event; + } + }; + + $updateFinalization = Mockery::mock(); + $updateFinalization->shouldReceive('isRequired')->once()->andReturn(false); + $updateFinalization->shouldReceive('finalizeUpdate')->once()->andReturnUsing(static function () use (&$sequence): void { + $sequence[] = 'finalize-update'; + }); + + $staffService = Mockery::mock(Box\Mod\Staff\Service::class); + $staffService->shouldReceive('checkPermissionsAndThrowException') + ->once() + ->with('system', 'system_update', null, $admin); + + $di = container(); + $di['event_dispatcher'] = $eventDispatcher; + $di['update_finalization'] = $updateFinalization; + $di['logger'] = new Tests\Helpers\TestLogger(); + $di['mod_service'] = $di->protect(fn (string $serviceName): mixed => $serviceName === 'Staff' ? $staffService : false); + $api->setDi($di); + + expect($api->finalize_update())->toBeTrue() + ->and($sequence)->toBe(['finalize-update', 'after-event']) + ->and($eventDispatcher->events)->toHaveCount(1) + ->and($eventDispatcher->events[0])->toBeInstanceOf(AfterAdminUpdateCoreEvent::class); +}); + +test('manual update dispatches before and after events around the update', function (): void { + $api = apiEndpoint(new Box\Mod\System\Api\Admin()); + $admin = Tests\Helpers\admin(['id' => 1]); + $api->setIdentity($admin); + + $sequence = []; + $eventDispatcher = new class($sequence) { + public array $events = []; + + public function __construct(private array &$sequence) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + $this->sequence[] = $event instanceof BeforeAdminManualUpdateEvent ? 'before-event' : 'after-event'; + + return $event; + } + }; + + $updater = Mockery::mock(); + $updater->shouldReceive('performManualUpdate')->once()->andReturnUsing(static function () use (&$sequence): void { + $sequence[] = 'perform-manual-update'; + }); + + $staffService = Mockery::mock(Box\Mod\Staff\Service::class); + $staffService->shouldReceive('checkPermissionsAndThrowException') + ->once() + ->with('system', 'system_update', null, $admin); + + $di = container(); + $di['event_dispatcher'] = $eventDispatcher; + $di['updater'] = $updater; + $di['logger'] = new Tests\Helpers\TestLogger(); + $di['mod_service'] = $di->protect(fn (string $serviceName): mixed => $serviceName === 'Staff' ? $staffService : false); + $api->setDi($di); + + expect($api->manual_update())->toBeTrue() + ->and($sequence)->toBe(['before-event', 'perform-manual-update', 'after-event']) + ->and($eventDispatcher->events)->toHaveCount(2) + ->and($eventDispatcher->events[0])->toBeInstanceOf(BeforeAdminManualUpdateEvent::class) + ->and($eventDispatcher->events[1])->toBeInstanceOf(AfterAdminManualUpdateEvent::class); +}); + test('update cache settings clears the saved redis password only when explicitly requested', function (): void { $api = apiEndpoint(new Box\Mod\System\Api\Admin()); $originalConfig = Config::getConfig(); diff --git a/src/modules/System/tests/Unit/ServiceTest.php b/src/modules/System/tests/Unit/ServiceTest.php index df5ba9d99..a662b3d5f 100644 --- a/src/modules/System/tests/Unit/ServiceTest.php +++ b/src/modules/System/tests/Unit/ServiceTest.php @@ -309,10 +309,31 @@ test('updateParams updates system parameters in a single flush', function (): vo $data = [ 'company_name' => 'Inc. Test', 'company_email' => 'work@example.eu', + 'smtp_password' => 'do-not-expose-this-value', ]; - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->atLeast()->once(); + $sequence = []; + $eventDispatcher = new class($sequence) { + /** @var list */ + public array $events = []; + + /** @var list */ + private array $sequence; + + /** @param list $sequence */ + public function __construct(array &$sequence) + { + $this->sequence = &$sequence; + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->events[] = $event; + $this->sequence[] = $event instanceof Box\Mod\System\Event\BeforeAdminSettingsUpdateEvent ? 'typed-before' : 'typed-after'; + + return $event; + } + }; $logStub = $this->createStub(FOSSBilling\Logger::class); @@ -323,28 +344,35 @@ test('updateParams updates system parameters in a single flush', function (): vo $settingRepository = Mockery::mock(Box\Mod\System\Repository\SettingRepository::class); $settingRepository->shouldReceive('findOneByParam')->with('company_name')->andReturn($companyName); $settingRepository->shouldReceive('findOneByParam')->with('company_email')->andReturn(null); + $settingRepository->shouldReceive('findOneByParam')->with('smtp_password')->andReturn(null); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = $eventDispatcher; $di['logger'] = $logStub; $di['mod_service'] = $di->protect(fn (): object => $staffServiceMock); $di['em']->shouldReceive('getRepository')->with(Box\Mod\System\Entity\Setting::class)->andReturn($settingRepository); - $di['em']->shouldReceive('persist')->once(); - $di['em']->shouldReceive('flush')->once(); + $di['em']->shouldReceive('persist')->twice(); + $di['em']->shouldReceive('flush')->once()->andReturnUsing(static function () use (&$sequence): void { + $sequence[] = 'flush'; + }); $service->setDi($di); $result = $service->updateParams($data); expect($result)->toBeBool(); expect($result)->toBeTrue(); expect($companyName->getValue())->toBe('Inc. Test'); + expect($sequence)->toBe(['typed-before', 'flush', 'typed-after']); + expect($eventDispatcher->events)->toHaveCount(2); + expect($eventDispatcher->events[0])->toBeInstanceOf(Box\Mod\System\Event\BeforeAdminSettingsUpdateEvent::class); + expect($eventDispatcher->events[0]->parameterNames)->toBe(array_keys($data)); + expect(property_exists($eventDispatcher->events[0], 'data'))->toBeFalse(); + expect($eventDispatcher->events[0]->parameterNames)->not->toContain('do-not-expose-this-value'); + expect($eventDispatcher->events[1])->toBeInstanceOf(Box\Mod\System\Event\AfterAdminSettingsUpdateEvent::class); }); test('updateParams denies a mixed-case guarded key without the company permission', function (): void { $service = new Service(); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->once(); - $staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class); $staffServiceMock->shouldReceive('hasPermission')->once()->with(null, 'system', 'manage_company_details')->andReturn(false); @@ -352,7 +380,12 @@ test('updateParams denies a mixed-case guarded key without the company permissio $settingRepository->shouldReceive('findOneByParam')->never(); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = new class { + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + return $event; + } + }; $di['mod_service'] = $di->protect(fn (): object => $staffServiceMock); $di['em']->shouldReceive('getRepository')->with(Box\Mod\System\Entity\Setting::class)->andReturn($settingRepository); $service->setDi($di); @@ -366,9 +399,6 @@ test('updateParams denies a mixed-case guarded key without the company permissio test('updateParams denies a mixed-case legal key without the legal permission', function (): void { $service = new Service(); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->once(); - $staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class); $staffServiceMock->shouldReceive('hasPermission')->once()->with(null, 'system', 'manage_company_legal')->andReturn(false); @@ -376,7 +406,12 @@ test('updateParams denies a mixed-case legal key without the legal permission', $settingRepository->shouldReceive('findOneByParam')->never(); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = new class { + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + return $event; + } + }; $di['mod_service'] = $di->protect(fn (): object => $staffServiceMock); $di['em']->shouldReceive('getRepository')->with(Box\Mod\System\Entity\Setting::class)->andReturn($settingRepository); $service->setDi($di); @@ -404,9 +439,6 @@ test('setParamValue skips a mixed-case guarded key without the company permissio test('updateParams rejects a key with a trailing space', function (): void { $service = new Service(); - $eventMock = Mockery::mock('\Box_EventManager'); - $eventMock->shouldReceive('fire')->once(); - $staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class); $staffServiceMock->shouldReceive('hasPermission')->andReturn(true); @@ -414,7 +446,12 @@ test('updateParams rejects a key with a trailing space', function (): void { $settingRepository->shouldReceive('findOneByParam')->never(); $di = container(); - $di['events_manager'] = $eventMock; + $di['event_dispatcher'] = new class { + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + return $event; + } + }; $di['mod_service'] = $di->protect(fn (): object => $staffServiceMock); $di['em']->shouldReceive('getRepository')->with(Box\Mod\System\Entity\Setting::class)->andReturn($settingRepository); $service->setDi($di); diff --git a/src/modules/Theme/Controller/Admin.php b/src/modules/Theme/Controller/Admin.php index 0b6cfa874..9cbfee492 100644 --- a/src/modules/Theme/Controller/Admin.php +++ b/src/modules/Theme/Controller/Admin.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Box\Mod\Theme\Controller; +use Box\Mod\Theme\Event\BeforeAdminThemeSettingsSaveEvent; use Symfony\Component\HttpFoundation\Response; class Admin implements \FOSSBilling\InjectionAwareInterface @@ -40,7 +41,11 @@ class Admin implements \FOSSBilling\InjectionAwareInterface public function save_theme_settings(\Box_App $app, $theme): Response { $body = $app->getRequest()->request->all(); - $this->di['events_manager']->fire(['event' => 'onBeforeThemeSettingsSave', 'params' => $body]); + $settingNames = array_values(array_diff( + array_map(static fn (int|string $key): string => (string) $key, array_keys($body)), + ['save-current-setting', 'save-current-setting-preset'], + )); + $this->di['event_dispatcher']->dispatch(new BeforeAdminThemeSettingsSaveEvent((string) $theme, $settingNames)); $api = $this->di['api_admin']; diff --git a/src/modules/Theme/Event/BeforeAdminThemeSettingsSaveEvent.php b/src/modules/Theme/Event/BeforeAdminThemeSettingsSaveEvent.php new file mode 100644 index 000000000..cb4cd52c1 --- /dev/null +++ b/src/modules/Theme/Event/BeforeAdminThemeSettingsSaveEvent.php @@ -0,0 +1,25 @@ + $settingNames + */ + public function __construct( + public readonly string $themeName, + public readonly array $settingNames, + ) { + } +} diff --git a/src/modules/Theme/tests/Unit/Controller/AdminTest.php b/src/modules/Theme/tests/Unit/Controller/AdminTest.php index 303dd23c5..a8a8b0ae3 100644 --- a/src/modules/Theme/tests/Unit/Controller/AdminTest.php +++ b/src/modules/Theme/tests/Unit/Controller/AdminTest.php @@ -10,6 +10,7 @@ declare(strict_types=1); +use Box\Mod\Theme\Event\BeforeAdminThemeSettingsSaveEvent; use Box\Mod\Theme\Model\Theme; use FOSSBilling\Sanitizer\BrowserHtmlSanitizer; use FOSSBilling\Twig\SandboxedStringRenderer; @@ -144,9 +145,11 @@ test('getTheme renders theme preset', function (): void { $controller->get_theme($boxAppMock, 'default/client'); }); -test('save theme settings reads body from request and strips preset control keys', function (): void { +test('save theme settings dispatches safe typed event and strips preset control keys', function (): void { $controller = new Box\Mod\Theme\Controller\Admin(); $di = container(); + $steps = []; + $events = []; $themeMock = Mockery::mock(Theme::class); $themeMock->shouldReceive('getName')->andReturn('default/client'); @@ -162,28 +165,41 @@ test('save theme settings reads body from request and strips preset control keys ->once() ->with($themeMock, 'MyPreset', Mockery::on(fn (array $body): bool => !array_key_exists('save-current-setting', $body) && !array_key_exists('save-current-setting-preset', $body) - && $body['color'] === 'blue')); + && $body['color'] === 'blue' + && $body['api_key'] === 'never-expose-this-value')); $themeServiceMock->shouldReceive('regenerateThemeCssAndJsFiles'); $themeServiceMock->shouldReceive('regenerateThemeSettingsDataFile'); $modMock = Mockery::mock(FOSSBilling\Module::class); $modMock->shouldReceive('getService')->andReturn($themeServiceMock); - $eventsManager = Mockery::mock(); - $eventsManager->shouldReceive('fire') - ->once() - ->with(Mockery::on(fn (array $event): bool => $event['event'] === 'onBeforeThemeSettingsSave' - && $event['params']['color'] === 'blue' - && $event['params']['save-current-setting'] === '1')); + $eventDispatcher = new class($steps, $events) { + public function __construct(private array &$steps, private array &$events) + { + } + + public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event + { + $this->steps[] = 'event'; + $this->events[] = $event; + + return $event; + } + }; $di['api_admin'] = Mockery::mock(); $di['is_admin_logged'] = true; - $di['mod'] = $di->protect(fn () => $modMock); - $di['events_manager'] = $eventsManager; + $di['mod'] = $di->protect(function () use ($modMock, &$steps) { + $steps[] = 'module'; + + return $modMock; + }); + $di['event_dispatcher'] = $eventDispatcher; $controller->setDi($di); $request = Symfony\Component\HttpFoundation\Request::create('/theme/default/client', 'POST', [ 'color' => 'blue', + 'api_key' => 'never-expose-this-value', 'save-current-setting' => '1', 'save-current-setting-preset' => 'My Preset', ]); @@ -196,7 +212,13 @@ test('save theme settings reads body from request and strips preset control keys ->andReturn(new Symfony\Component\HttpFoundation\RedirectResponse('/theme/default/client')); $response = $controller->save_theme_settings($boxAppMock, 'default/client'); - expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\RedirectResponse::class); + expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\RedirectResponse::class) + ->and($steps)->toBe(['event', 'module']) + ->and($events)->toHaveCount(1) + ->and($events[0])->toBeInstanceOf(BeforeAdminThemeSettingsSaveEvent::class) + ->and($events[0]->themeName)->toBe('default/client') + ->and($events[0]->settingNames)->toBe(['color', 'api_key']) + ->and($events[0]->settingNames)->not->toContain('never-expose-this-value'); }); test('default/client footer link checkboxes submit canonical enabled values', function (): void { diff --git a/src/modules/Widgets/Service.php b/src/modules/Widgets/Service.php index 001eabe28..6e5dd033a 100644 --- a/src/modules/Widgets/Service.php +++ b/src/modules/Widgets/Service.php @@ -12,8 +12,11 @@ declare(strict_types=1); namespace Box\Mod\Widgets; use Box\Mod\Extension\Entity\Extension; +use Box\Mod\Extension\Event\AfterExtensionActivatedEvent; +use Box\Mod\Extension\Event\AfterExtensionDeactivatedEvent; use FOSSBilling\InjectionAwareInterface; use FOSSBilling\Interfaces\WidgetProviderInterface; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\Cache\ItemInterface; class Service implements InjectionAwareInterface @@ -203,34 +206,22 @@ class Service implements InjectionAwareInterface /** * Event handler: Invalidate cache when a module is activated. */ - public static function onAfterAdminActivateExtension(\Box_Event $event): void + #[AsEventListener] + public function onExtensionActivated(AfterExtensionActivatedEvent $event): void { - $params = $event->getParameters(); - - if (isset($params['id'])) { - $di = $event->getDi(); - $ext = $di['em']->getRepository(Extension::class)->find((int) $params['id']); - - if ($ext !== null && $ext->getType() === Extension::TYPE_MOD) { - $di['mod_service']('Widgets')->invalidateCache(); - } + if ($event->type === Extension::TYPE_MOD) { + $this->invalidateCache(); } - - $event->setReturnValue(true); } /** * Event handler: Invalidate cache when a module is deactivated. */ - public static function onAfterAdminDeactivateExtension(\Box_Event $event): void + #[AsEventListener] + public function onExtensionDeactivated(AfterExtensionDeactivatedEvent $event): void { - $params = $event->getParameters(); - - if (($params['type'] ?? null) === 'mod') { - $di = $event->getDi(); - $di['mod_service']('Widgets')->invalidateCache(); + if ($event->type === Extension::TYPE_MOD) { + $this->invalidateCache(); } - - $event->setReturnValue(true); } } diff --git a/src/modules/Widgets/tests/Unit/ServiceTest.php b/src/modules/Widgets/tests/Unit/ServiceTest.php new file mode 100644 index 000000000..673d8139d --- /dev/null +++ b/src/modules/Widgets/tests/Unit/ServiceTest.php @@ -0,0 +1,44 @@ +deleted[] = $key; + + return true; + } + }; + + $di = container(); + $di['cache'] = $cache; + + $service = new Service(); + $service->setDi($di); + $dispatcher = new EventDispatcher( + static fn (): array => ['widgets'], + static fn (string $module): object => $service, + ); + + $dispatcher->dispatch(new AfterExtensionActivatedEvent(1, 'mod', 'example')); + $dispatcher->dispatch(new AfterExtensionDeactivatedEvent(1, 'mod', 'example')); + $dispatcher->dispatch(new AfterExtensionActivatedEvent(2, 'theme', 'example')); + + expect($cache->deleted)->toBe([Service::CACHE_KEY, Service::CACHE_KEY]); +}); diff --git a/tests/Helpers/Container.php b/tests/Helpers/Container.php index e7761a1d8..1ea7f1e55 100644 --- a/tests/Helpers/Container.php +++ b/tests/Helpers/Container.php @@ -125,14 +125,10 @@ function container(): Container return $dbal; }; - $di['events_manager'] = fn (): object => new class { - public array $events = []; - - public function fire(array|string $event): void - { - $this->events[] = $event; - } - }; + $di['event_dispatcher'] = fn (): \FOSSBilling\Events\EventDispatcher => new \FOSSBilling\Events\EventDispatcher( + static fn (): array => [], + static fn (string $module): object => throw new \LogicException('No module listeners are configured in the test container.'), + ); $di['auth'] = fn (): object => \Mockery::mock()->shouldIgnoreMissing(); $di['pager'] = fn (): object => new class { public function getDefaultPerPage(): int diff --git a/tests/Unit/Box/EventManagerTest.php b/tests/Unit/Box/EventManagerTest.php deleted file mode 100644 index e9cea6103..000000000 --- a/tests/Unit/Box/EventManagerTest.php +++ /dev/null @@ -1,71 +0,0 @@ -getName(); - } - - public static function onEveryEvent(Box_Event $event): void - { - self::$calls[] = 'global:' . $event->getName(); - } -} - -test('empty fire', function (): void { - $manager = new Box_EventManager(); - expect($manager->fire([]))->toBeFalse(); -}); - -test('fire reuses listener lookup and reloads it after invalidation', function (): void { - $di = container(); - $di['logger'] = new FOSSBilling\Logger(); - $di['mod_service'] = $di->protect(fn (): EventManagerTestListener => new EventManagerTestListener()); - - EventManagerTestListener::$calls = []; - - $connection = Mockery::mock(Doctrine\DBAL\Connection::class); - $connection->shouldReceive('fetchAllAssociative') - ->twice() - ->andReturn( - [ - ['rel_id' => 'example', 'meta_value' => 'onExample'], - ['rel_id' => 'example', 'meta_value' => 'onEveryEvent'], - ], - [ - ['rel_id' => 'example', 'meta_value' => 'onExample'], - ], - ); - $di['em']->shouldReceive('getConnection')->andReturn($connection); - - $manager = new Box_EventManager(); - $manager->setDi($di); - - $manager->fire(['event' => 'onExample']); - $manager->fire(['event' => 'onExample']); - $manager->clearListenerCache(); - $manager->fire(['event' => 'onExample']); - - expect(EventManagerTestListener::$calls)->toBe([ - 'specific:onExample', - 'global:onExample', - 'specific:onExample', - 'global:onExample', - 'specific:onExample', - ]); -}); diff --git a/tests/Unit/FOSSBilling/Events/EventDispatcherTest.php b/tests/Unit/FOSSBilling/Events/EventDispatcherTest.php new file mode 100644 index 000000000..6eab7be0d --- /dev/null +++ b/tests/Unit/FOSSBilling/Events/EventDispatcherTest.php @@ -0,0 +1,85 @@ +calls; + $event->setSubject('Updated by listener'); + } +} + +class_alias(TypedEventTestService::class, 'Box\\Mod\\TypedEventTest\\Service'); + +final class TypedEventPriorityTestService +{ + /** @var list */ + public array $calls = []; + + #[AsEventListener(priority: -10)] + public function later(BeforeGuestTicketCreateEvent $event): void + { + $this->calls[] = 'later'; + } + + #[AsEventListener(event: BeforeGuestTicketCreateEvent::class, priority: 10)] + public function earlier(BeforeGuestTicketCreateEvent $event): void + { + $this->calls[] = 'earlier'; + $event->stopPropagation(); + } +} + +class_alias(TypedEventPriorityTestService::class, 'Box\\Mod\\TypedEventPriorityTest\\Service'); + +test('typed listeners can mutate declared fields and refresh after activation', function (): void { + $modules = []; + $service = new TypedEventTestService(); + $dispatcher = new EventDispatcher( + static function () use (&$modules): array { + return $modules; + }, + static fn (string $module): object => $service, + ); + + $newEvent = static fn (): BeforeGuestTicketCreateEvent => new BeforeGuestTicketCreateEvent([], 'open', 'Original', 'Message'); + + expect($dispatcher->dispatch($newEvent())->getSubject())->toBe('Original'); + + $modules = ['typedEventTest', 'typedEventTest']; + $dispatcher->refresh(); + expect($dispatcher->dispatch($newEvent())->getSubject())->toBe('Updated by listener'); + expect($service->calls)->toBe(1); + + $modules = []; + $dispatcher->refresh(); + expect($dispatcher->dispatch($newEvent())->getSubject())->toBe('Original'); + expect($service->calls)->toBe(1); +}); + +test('typed listeners honor Symfony priorities and stop propagation', function (): void { + $service = new TypedEventPriorityTestService(); + $dispatcher = new EventDispatcher( + static fn (): array => ['typedEventPriorityTest'], + static fn (string $module): object => $service, + ); + + $event = new BeforeGuestTicketCreateEvent([], 'open', 'Subject', 'Message'); + expect($dispatcher->dispatch($event))->toBe($event) + ->and($service->calls)->toBe(['earlier']) + ->and($event->isPropagationStopped())->toBeTrue(); +}); diff --git a/tests/Unit/FOSSBilling/UpdatePatcherTest.php b/tests/Unit/FOSSBilling/UpdatePatcherTest.php index 1c169d3fe..8c287cd90 100644 --- a/tests/Unit/FOSSBilling/UpdatePatcherTest.php +++ b/tests/Unit/FOSSBilling/UpdatePatcherTest.php @@ -1256,7 +1256,10 @@ function mockPdoAllowingThemeMigrationCalls(): Mockery\MockInterface $pdo = Mockery::mock(PDO::class); $pdo->shouldReceive('prepare') - ->with(Mockery::on(fn (string $sql): bool => str_starts_with($sql, 'UPDATE setting') || str_starts_with($sql, 'UPDATE extension_meta'))) + ->with(Mockery::on(fn (string $sql): bool => str_starts_with($sql, 'UPDATE setting') + || str_starts_with($sql, 'UPDATE extension_meta') + || $sql === "DELETE FROM extension_meta WHERE extension = 'mod_hook' AND meta_key = 'listener'" + || $sql === "DELETE FROM extension WHERE type = 'hook'")) ->andReturn($statement); $pdo->shouldReceive('prepare') ->with(Mockery::on(fn (string $sql): bool => str_starts_with($sql, 'SELECT value FROM setting') || str_starts_with($sql, 'INSERT INTO setting'))) @@ -1265,11 +1268,28 @@ function mockPdoAllowingThemeMigrationCalls(): Mockery\MockInterface return $pdo; } +/** + * Allow the portable retired-hook cleanup statements in otherwise strict applyCorePatches() + * PDO mocks. The SQLite and strict-mock tests below cover the cleanup's behavior and SQL. + */ +function allowRetiredHookCleanupCalls(Mockery\MockInterface $pdo): void +{ + $statement = Mockery::mock(PDOStatement::class); + $statement->shouldReceive('execute')->with([])->andReturnTrue(); + + $pdo->shouldReceive('prepare') + ->with("DELETE FROM extension_meta WHERE extension = 'mod_hook' AND meta_key = 'listener'") + ->andReturn($statement); + $pdo->shouldReceive('prepare') + ->with("DELETE FROM extension WHERE type = 'hook'") + ->andReturn($statement); +} + test('applyCorePatches never runs a legacy MySQL patch on a non-MySQL driver, even if the patch level looks stale', function (): void { withNonMysqlDbDriver(function (): void { - // mockPdoAllowingThemeMigrationCalls() only accepts 'UPDATE setting'/'UPDATE extension_meta' - // prepare() calls; anything else (backtick-quoted identifiers, ALTER TABLE, SHOW COLUMNS, - // ...) would mean a legacy MySQL-only patch ran, which this test exists to catch. + // mockPdoAllowingThemeMigrationCalls() accepts the portable migrations and retired-hook + // cleanup; anything else (backtick-quoted identifiers, ALTER TABLE, SHOW COLUMNS, ...) would + // mean a legacy MySQL-only patch ran, which this test exists to catch. $pdo = mockPdoAllowingThemeMigrationCalls(); $pdo->shouldNotReceive('query'); @@ -1289,6 +1309,64 @@ test('applyCorePatches never runs a legacy MySQL patch on a non-MySQL driver, ev }); }); +test('applyCorePatches removes retired hook data on non-MySQL drivers and remains idempotent', function (): void { + withNonMysqlDbDriver(function (): void { + $pdo = new PDO('sqlite::memory:'); + $pdo->exec('CREATE TABLE setting (param TEXT PRIMARY KEY, value TEXT, public INTEGER, created_at TEXT, updated_at TEXT)'); + $pdo->exec('CREATE TABLE extension_meta (id INTEGER PRIMARY KEY, extension TEXT, rel_type TEXT, rel_id TEXT, meta_key TEXT, meta_value TEXT)'); + $pdo->exec('CREATE TABLE extension (id INTEGER PRIMARY KEY, type TEXT, name TEXT, status TEXT, version TEXT)'); + $pdo->exec("INSERT INTO extension_meta (extension, rel_type, rel_id, meta_key, meta_value) VALUES + ('mod_hook', 'listener', 'legacy-listener', 'listener', '{}'), + ('mod_hook', 'config', '', 'config', '{}'), + ('mod_invoice', 'listener', 'unrelated-listener', 'listener', '{}')"); + $pdo->exec("INSERT INTO extension (type, name, status, version) VALUES + ('hook', 'legacy-hook-package', 'installed', '1.0.0'), + ('mod', 'invoice', 'installed', '1.0.0')"); + + $di = new Pimple\Container(); + $di['pdo'] = $pdo; + $di['logger'] = new Tests\Helpers\TestLogger(); + + $patcher = new UpdatePatcher(); + $patcher->setDi($di); + + // The portable cleanup runs from applyCorePatches on every driver. Running the + // upgrade twice also proves the deletes are safe when no retired rows remain. + $patcher->applyCorePatches(force: true); + $patcher->applyCorePatches(force: true); + + expect((int) $pdo->query("SELECT COUNT(*) FROM extension_meta WHERE extension = 'mod_hook' AND meta_key = 'listener'")->fetchColumn())->toBe(0) + ->and((int) $pdo->query("SELECT COUNT(*) FROM extension WHERE type = 'hook'")->fetchColumn())->toBe(0) + ->and((int) $pdo->query("SELECT COUNT(*) FROM extension_meta WHERE extension = 'mod_hook' AND meta_key = 'config'")->fetchColumn())->toBe(1) + ->and((int) $pdo->query("SELECT COUNT(*) FROM extension_meta WHERE extension = 'mod_invoice' AND meta_key = 'listener'")->fetchColumn())->toBe(1) + ->and((int) $pdo->query("SELECT COUNT(*) FROM extension WHERE type = 'mod' AND name = 'invoice'")->fetchColumn())->toBe(1); + }); +}); + +test('retired hook cleanup uses both parameterless portable delete statements', function (): void { + withNonMysqlDbDriver(function (): void { + $listenerStatement = Mockery::mock(PDOStatement::class); + $listenerStatement->expects('execute')->with([])->andReturnTrue(); + $packageStatement = Mockery::mock(PDOStatement::class); + $packageStatement->expects('execute')->with([])->andReturnTrue(); + + $pdo = Mockery::mock(PDO::class); + $pdo->expects('prepare') + ->with("DELETE FROM extension_meta WHERE extension = 'mod_hook' AND meta_key = 'listener'") + ->andReturn($listenerStatement); + $pdo->expects('prepare') + ->with("DELETE FROM extension WHERE type = 'hook'") + ->andReturn($packageStatement); + + $di = new Pimple\Container(); + $di['pdo'] = $pdo; + + $patcher = new UpdatePatcher(); + $patcher->setDi($di); + (new ReflectionMethod($patcher, 'removeRetiredHookData'))->invoke($patcher); + }); +}); + test('migrateThemePackageLayout renames admin_default/huraga when only the old directory exists', function (): void { $adminDefault = Path::join(PATH_THEMES, 'admin_default'); $huraga = Path::join(PATH_THEMES, 'huraga'); @@ -1397,6 +1475,7 @@ test('applyCorePatches migrates the theme setting values on a non-MySQL driver, $pdo->shouldReceive('prepare') ->with(Mockery::on(fn (string $sql): bool => str_starts_with($sql, 'SELECT value FROM setting') || str_starts_with($sql, 'INSERT INTO setting'))) ->andReturn($seedStatement); + allowRetiredHookCleanupCalls($pdo); $di = new Pimple\Container(); $di['pdo'] = $pdo; @@ -1445,6 +1524,7 @@ test('applyCorePatches migrates saved theme settings/presets in extension_meta o $pdo->shouldReceive('prepare') ->with(Mockery::on(fn (string $sql): bool => str_starts_with($sql, 'SELECT value FROM setting') || str_starts_with($sql, 'INSERT INTO setting'))) ->andReturn($seedStatement); + allowRetiredHookCleanupCalls($pdo); $di = new Pimple\Container(); $di['pdo'] = $pdo;