diff --git a/Civi/RemoteTools/ActionHandler/RemoteActionsHandlerTransactionDecorator.php b/Civi/RemoteTools/ActionHandler/RemoteActionsHandlerTransactionDecorator.php new file mode 100644 index 0000000..f23e4a1 --- /dev/null +++ b/Civi/RemoteTools/ActionHandler/RemoteActionsHandlerTransactionDecorator.php @@ -0,0 +1,85 @@ +. + */ + +declare(strict_types = 1); + +namespace Civi\RemoteTools\ActionHandler; + +use Civi\RemoteTools\Api4\Action\RemoteDeleteAction; +use Civi\RemoteTools\Api4\Action\RemoteSubmitCreateFormAction; +use Civi\RemoteTools\Api4\Action\RemoteSubmitUpdateFormAction; +use Civi\RemoteTools\Database\TransactionFactory; + +final class RemoteActionsHandlerTransactionDecorator extends AbstractRemoteEntityActionsHandlerDecorator { + + private TransactionFactory $transactionFactory; + + public function __construct(RemoteEntityActionsHandlerInterface $handler, TransactionFactory $transactionFactory) { + parent::__construct($handler); + $this->transactionFactory = $transactionFactory; + } + + public function delete(RemoteDeleteAction $action): array { + $transaction = $this->transactionFactory->createTransaction(); + try { + return parent::delete($action); + } + // @phpstan-ignore-next-line Dead catch clause. + catch (\Throwable $e) { + // This just sets a flag. Rollback is actually performed on commit() method call in finally block. + $transaction->rollback(); + + throw $e; + } + finally { + $transaction->commit(); + } + } + + public function submitCreateForm(RemoteSubmitCreateFormAction $action): array { + $transaction = $this->transactionFactory->createTransaction(); + try { + return parent::submitCreateForm($action); + } + // @phpstan-ignore-next-line Dead catch clause. + catch (\Throwable $e) { + $transaction->rollback(); + + throw $e; + } + finally { + $transaction->commit(); + } + } + + public function submitUpdateForm(RemoteSubmitUpdateFormAction $action): array { + $transaction = $this->transactionFactory->createTransaction(); + try { + return parent::submitUpdateForm($action); + } + // @phpstan-ignore-next-line Dead catch clause. + catch (\Throwable $e) { + $transaction->rollback(); + + throw $e; + } + finally { + $transaction->commit(); + } + } + +} diff --git a/Civi/RemoteTools/Database/TransactionFactory.php b/Civi/RemoteTools/Database/TransactionFactory.php new file mode 100644 index 0000000..c367c0a --- /dev/null +++ b/Civi/RemoteTools/Database/TransactionFactory.php @@ -0,0 +1,31 @@ +. + */ + +declare(strict_types = 1); + +namespace Civi\RemoteTools\Database; + +/** + * @codeCoverageIgnore + */ +class TransactionFactory { + + public function createTransaction(): \CRM_Core_Transaction { + return \CRM_Core_Transaction::create(); + } + +} diff --git a/Civi/RemoteTools/DependencyInjection/Compiler/ActionHandlerPass.php b/Civi/RemoteTools/DependencyInjection/Compiler/ActionHandlerPass.php index 74d2560..107214b 100644 --- a/Civi/RemoteTools/DependencyInjection/Compiler/ActionHandlerPass.php +++ b/Civi/RemoteTools/DependencyInjection/Compiler/ActionHandlerPass.php @@ -22,6 +22,8 @@ use Civi\Api4\Generic\AbstractAction; use Civi\RemoteTools\ActionHandler\ActionHandlerInterface; use Civi\RemoteTools\ActionHandler\ActionHandlerProvider; +use Civi\RemoteTools\ActionHandler\RemoteActionsHandlerTransactionDecorator; +use Civi\RemoteTools\DependencyInjection\Compiler\Traits\DecorateServiceTrait; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -33,6 +35,8 @@ */ final class ActionHandlerPass implements CompilerPassInterface { + use DecorateServiceTrait; + public static function buildHandlerKey(string $entityName, string $actionName, ?string $profileName): string { if (NULL === $profileName) { return $entityName . '.' . $actionName; @@ -79,6 +83,15 @@ public function process(ContainerBuilder $container): void { } } + foreach ($handlerServices as $handlerKey => $handlerService) { + $this->decorateService( + $container, + $handlerService->__toString(), + RemoteActionsHandlerTransactionDecorator::class, + $handlerKey + ); + } + $container->getDefinition(ActionHandlerProvider::class) ->addArgument(ServiceLocatorTagPass::register($container, $handlerServices)); } diff --git a/Civi/RemoteTools/DependencyInjection/Compiler/Traits/DecorateServiceTrait.php b/Civi/RemoteTools/DependencyInjection/Compiler/Traits/DecorateServiceTrait.php new file mode 100644 index 0000000..bb6b6b0 --- /dev/null +++ b/Civi/RemoteTools/DependencyInjection/Compiler/Traits/DecorateServiceTrait.php @@ -0,0 +1,44 @@ +. + */ + +declare(strict_types = 1); + +namespace Civi\RemoteTools\DependencyInjection\Compiler\Traits; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +trait DecorateServiceTrait { + + /** + * @phpstan-param class-string $decoratorClass + * @param int|string $argumentKey + */ + protected function decorateService( + ContainerBuilder $container, + string $id, + string $decoratorClass, + string $serviceIdPostfix, + $argumentKey = 0 + ): void { + $decoratorId = $decoratorClass . ':' . $serviceIdPostfix; + $container->autowire($decoratorId, $decoratorClass) + ->setDecoratedService($id) + ->setArgument($argumentKey, new Reference($decoratorId . '.inner')); + } + +} diff --git a/services/remote-tools.php b/services/remote-tools.php index 6272f4e..2bd17f2 100644 --- a/services/remote-tools.php +++ b/services/remote-tools.php @@ -33,6 +33,7 @@ use Civi\RemoteTools\Contact\RemoteContactIdResolverInterface; use Civi\RemoteTools\Contact\RemoteContactIdResolverProvider; use Civi\RemoteTools\Contact\RemoteContactIdResolverProviderInterface; +use Civi\RemoteTools\Database\TransactionFactory; use Civi\RemoteTools\DependencyInjection\Compiler\ActionHandlerPass; use Civi\RemoteTools\DependencyInjection\Compiler\RemoteEntityProfilePass; use Civi\RemoteTools\EntityProfile\Helper\ProfileEntityDeleter; @@ -58,6 +59,8 @@ $container->autowire(RequestContextInterface::class, RequestContext::class) ->setPublic(TRUE); +$container->autowire(TransactionFactory::class); + $container->autowire(ActionHandlerProviderInterface::class, ActionHandlerProviderCollection::class) ->addArgument(new TaggedIteratorArgument(ActionHandlerProviderInterface::SERVICE_TAG)); diff --git a/tests/phpunit/Civi/RemoteTools/ActionHandler/RemoteActionsHandlerTransactionDecoratorTest.php b/tests/phpunit/Civi/RemoteTools/ActionHandler/RemoteActionsHandlerTransactionDecoratorTest.php new file mode 100644 index 0000000..21f344f --- /dev/null +++ b/tests/phpunit/Civi/RemoteTools/ActionHandler/RemoteActionsHandlerTransactionDecoratorTest.php @@ -0,0 +1,158 @@ +. + */ + +declare(strict_types = 1); + +namespace Civi\RemoteTools\ActionHandler; + +use Civi\RemoteTools\Api4\Action\RemoteDeleteAction; +use Civi\RemoteTools\Api4\Action\RemoteSubmitCreateFormAction; +use Civi\RemoteTools\Api4\Action\RemoteSubmitUpdateFormAction; +use Civi\RemoteTools\Database\TransactionFactory; +use Civi\RemoteTools\PHPUnit\Traits\CreateMockTrait; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * @covers \Civi\RemoteTools\ActionHandler\RemoteActionsHandlerTransactionDecorator + */ +final class RemoteActionsHandlerTransactionDecoratorTest extends TestCase { + + use CreateMockTrait; + + private RemoteActionsHandlerTransactionDecorator $decorator; + + /** + * @var \Civi\RemoteTools\ActionHandler\RemoteEntityActionsHandlerInterface&\PHPUnit\Framework\MockObject\MockObject + */ + private MockObject $handlerMock; + + /** + * @var \Civi\RemoteTools\Database\TransactionFactory&\PHPUnit\Framework\MockObject\MockObject + */ + private MockObject $transactionFactoryMock; + + protected function setUp(): void { + parent::setUp(); + $this->handlerMock = $this->createMock(RemoteEntityActionsHandlerInterface::class); + $this->transactionFactoryMock = $this->createMock(TransactionFactory::class); + $this->decorator = new RemoteActionsHandlerTransactionDecorator( + $this->handlerMock, + $this->transactionFactoryMock + ); + } + + public function testDelete(): void { + $action = $this->createApi4ActionMock(RemoteDeleteAction::class, 'RemoteEntity', 'delete'); + + $transactionMock = $this->createMock(\CRM_Core_Transaction::class); + $this->transactionFactoryMock->method('createTransaction') + ->willReturn($transactionMock); + + $transactionMock->expects(static::once())->method('commit'); + $this->handlerMock->method('delete') + ->with($action) + ->willReturn(['foo' => 'bar']); + + static::assertSame(['foo' => 'bar'], $this->decorator->delete($action)); + } + + public function testDeleteRollback(): void { + $action = $this->createApi4ActionMock(RemoteDeleteAction::class, 'RemoteEntity', 'delete'); + + $transactionMock = $this->createMock(\CRM_Core_Transaction::class); + $this->transactionFactoryMock->method('createTransaction') + ->willReturn($transactionMock); + + $exception = new \Exception('test'); + $transactionMock->expects(static::once())->method('rollback'); + $transactionMock->expects(static::once())->method('commit'); + $this->handlerMock->method('delete') + ->with($action) + ->willThrowException($exception); + + static::expectExceptionObject($exception); + $this->decorator->delete($action); + } + + public function testSubmitCreateForm(): void { + $action = $this->createApi4ActionMock(RemoteSubmitCreateFormAction::class, 'RemoteEntity', 'submitCreateForm'); + + $transactionMock = $this->createMock(\CRM_Core_Transaction::class); + $this->transactionFactoryMock->method('createTransaction') + ->willReturn($transactionMock); + + $transactionMock->expects(static::once())->method('commit'); + $this->handlerMock->method('submitCreateForm') + ->with($action) + ->willReturn(['foo' => 'bar']); + + static::assertSame(['foo' => 'bar'], $this->decorator->submitCreateForm($action)); + } + + public function testSubmitCreateFormRollback(): void { + $action = $this->createApi4ActionMock(RemoteSubmitCreateFormAction::class, 'RemoteEntity', 'submitCreateForm'); + + $transactionMock = $this->createMock(\CRM_Core_Transaction::class); + $this->transactionFactoryMock->method('createTransaction') + ->willReturn($transactionMock); + + $exception = new \Exception('test'); + $transactionMock->expects(static::once())->method('rollback'); + $transactionMock->expects(static::once())->method('commit'); + $this->handlerMock->method('submitCreateForm') + ->with($action) + ->willThrowException($exception); + + static::expectExceptionObject($exception); + $this->decorator->submitCreateForm($action); + } + + public function testSubmitUpdateForm(): void { + $action = $this->createApi4ActionMock(RemoteSubmitUpdateFormAction::class, 'RemoteEntity', 'submitUpdateForm'); + + $transactionMock = $this->createMock(\CRM_Core_Transaction::class); + $this->transactionFactoryMock->method('createTransaction') + ->willReturn($transactionMock); + + $transactionMock->expects(static::once())->method('commit'); + $this->handlerMock->method('submitUpdateForm') + ->with($action) + ->willReturn(['foo' => 'bar']); + + static::assertSame(['foo' => 'bar'], $this->decorator->submitUpdateForm($action)); + } + + public function testSubmitUpdateFormRollback(): void { + $action = $this->createApi4ActionMock(RemoteSubmitUpdateFormAction::class, 'RemoteEntity', 'submitUpdateForm'); + + $transactionMock = $this->createMock(\CRM_Core_Transaction::class); + $this->transactionFactoryMock->method('createTransaction') + ->willReturn($transactionMock); + + $exception = new \Exception('test'); + $transactionMock->expects(static::once())->method('rollback'); + $transactionMock->expects(static::once())->method('commit'); + $this->handlerMock->method('submitUpdateForm') + ->with($action) + ->willThrowException($exception); + + static::expectExceptionObject($exception); + $this->decorator->submitUpdateForm($action); + } + +}