From c47d1813500fa4befd165ac819ed45206c1af38b Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Mon, 2 Dec 2024 14:29:03 +0100 Subject: [PATCH] TASK: Test `ProjectionTransactionTrait` when using external projections CASE: $this->dbal->isTransactionActive() === false --- .../AbstractSubscriptionEngineTestCase.php | 24 ++- .../ExternalProjectionErrorTest.php | 36 +++++ .../Subscription/ProjectionErrorTest.php | 139 +---------------- .../ProjectionRollbackTestTrait.php | 144 ++++++++++++++++++ 4 files changed, 204 insertions(+), 139 deletions(-) create mode 100644 Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/ExternalProjectionErrorTest.php create mode 100644 Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/ProjectionRollbackTestTrait.php diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/AbstractSubscriptionEngineTestCase.php b/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/AbstractSubscriptionEngineTestCase.php index 73b11b5ef8..2a3476bc98 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/AbstractSubscriptionEngineTestCase.php +++ b/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/AbstractSubscriptionEngineTestCase.php @@ -44,6 +44,8 @@ */ abstract class AbstractSubscriptionEngineTestCase extends TestCase // we don't use Flows functional test case as it would reset the database afterwards { + protected static ContentRepositoryId $contentRepositoryId; + protected ContentRepository $contentRepository; protected SubscriptionEngine $subscriptionEngine; @@ -56,16 +58,20 @@ abstract class AbstractSubscriptionEngineTestCase extends TestCase // we don't u protected CatchUpHookInterface&MockObject $catchupHookForFakeProjection; + public static function setUpBeforeClass(): void + { + static::$contentRepositoryId = ContentRepositoryId::fromString('t_subscription'); + } + public function setUp(): void { if ($this->getObject(Connection::class)->getDatabasePlatform() instanceof PostgreSQLPlatform) { $this->markTestSkipped('TODO: The content graph is not available in postgres currently: https://github.com/neos/neos-development-collection/issues/3855'); } - $contentRepositoryId = ContentRepositoryId::fromString('t_subscription'); $this->resetDatabase( $this->getObject(Connection::class), - $contentRepositoryId, + self::$contentRepositoryId, keepSchema: true ); @@ -78,10 +84,12 @@ public function setUp(): void $this->fakeProjection ); - $this->secondFakeProjection = new DebugEventProjection( - sprintf('cr_%s_debug_projection', $contentRepositoryId->value), - $this->getObject(Connection::class) - ); + if (!isset($this->secondFakeProjection)) { + $this->secondFakeProjection = new DebugEventProjection( + sprintf('cr_%s_debug_projection', self::$contentRepositoryId->value), + $this->getObject(Connection::class) + ); + } FakeProjectionFactory::setProjection( 'second', @@ -98,9 +106,9 @@ public function setUp(): void FakeNodeTypeManagerFactory::setConfiguration([]); FakeContentDimensionSourceFactory::setWithoutDimensions(); - $this->getObject(ContentRepositoryRegistry::class)->resetFactoryInstance($contentRepositoryId); + $this->getObject(ContentRepositoryRegistry::class)->resetFactoryInstance(self::$contentRepositoryId); - $this->setupContentRepositoryDependencies($contentRepositoryId); + $this->setupContentRepositoryDependencies(self::$contentRepositoryId); } final protected function setupContentRepositoryDependencies(ContentRepositoryId $contentRepositoryId) diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/ExternalProjectionErrorTest.php b/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/ExternalProjectionErrorTest.php new file mode 100644 index 0000000000..de79f4f4db --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/ExternalProjectionErrorTest.php @@ -0,0 +1,36 @@ +getObject(EntityManagerInterface::class); + + if (!isset(self::$secondConnection)) { + self::$secondConnection = DriverManager::getConnection( + $entityManager->getConnection()->getParams(), + $entityManager->getConfiguration(), + $entityManager->getEventManager() + ); + } + + $this->secondFakeProjection = new DebugEventProjection( + sprintf('cr_%s_debug_projection', self::$contentRepositoryId->value), + self::$secondConnection + ); + } +} diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/ProjectionErrorTest.php b/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/ProjectionErrorTest.php index 78d3054c6b..909131e789 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/ProjectionErrorTest.php +++ b/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/ProjectionErrorTest.php @@ -13,7 +13,6 @@ use Neos\ContentRepository\Core\Subscription\Engine\Errors; use Neos\ContentRepository\Core\Subscription\Engine\ProcessedResult; use Neos\ContentRepository\Core\Subscription\Engine\Result; -use Neos\ContentRepository\Core\Subscription\Engine\SubscriptionEngineCriteria; use Neos\ContentRepository\Core\Subscription\ProjectionSubscriptionStatus; use Neos\ContentRepository\Core\Subscription\SubscriptionError; use Neos\ContentRepository\Core\Subscription\SubscriptionId; @@ -23,12 +22,15 @@ final class ProjectionErrorTest extends AbstractSubscriptionEngineTestCase { + use ProjectionRollbackTestTrait; + /** @test */ public function projectionWithError() { $this->eventStore->setup(); $this->fakeProjection->expects(self::once())->method('setUp'); - $this->subscriptionEngine->setup(); + $result = $this->subscriptionEngine->setup(); + self::assertNull($result->errors); $this->fakeProjection->expects(self::any())->method('status')->willReturn(ProjectionStatus::ok()); $result = $this->subscriptionEngine->boot(); self::assertEquals(ProcessedResult::success(0), $result); @@ -84,8 +86,10 @@ public function fixFailedProjectionViaReset() $this->fakeProjection->expects(self::any())->method('status')->willReturn(ProjectionStatus::ok()); $this->fakeProjection->expects(self::once())->method('resetState'); $this->fakeProjection->expects(self::exactly(2))->method('apply'); - $this->subscriptionEngine->setup(); - $this->subscriptionEngine->boot(); + $result = $this->subscriptionEngine->setup(); + self::assertNull($result->errors); + $result = $this->subscriptionEngine->boot(); + self::assertNull($result->errors); // commit an event $this->commitExampleContentStreamEvent(); @@ -221,133 +225,6 @@ public function irreparableProjection() ); } - /** @test */ - public function projectionIsRolledBackAfterError() - { - $this->eventStore->setup(); - $this->fakeProjection->expects(self::once())->method('setUp'); - $this->fakeProjection->expects(self::once())->method('apply'); - $result = $this->subscriptionEngine->setup(); - self::assertNull($result->errors); - $result = $this->subscriptionEngine->boot(); - self::assertNull($result->errors); - - // commit an event - $this->commitExampleContentStreamEvent(); - - $exception = new \RuntimeException('This projection is kaputt.'); - - $this->secondFakeProjection->injectSaboteur(fn () => throw $exception); - - $expectedFailure = ProjectionSubscriptionStatus::create( - subscriptionId: SubscriptionId::fromString('Vendor.Package:SecondFakeProjection'), - subscriptionStatus: SubscriptionStatus::ERROR, - subscriptionPosition: SequenceNumber::none(), - subscriptionError: SubscriptionError::fromPreviousStatusAndException(SubscriptionStatus::ACTIVE, $exception), - setupStatus: ProjectionStatus::ok(), - ); - - self::assertEmpty( - $this->secondFakeProjection->getState()->findAppliedSequenceNumbers() - ); - - $result = $this->subscriptionEngine->catchUpActive(); - self::assertSame($result->errors?->first()->message, 'This projection is kaputt.'); - - self::assertEquals( - $expectedFailure, - $this->subscriptionStatus('Vendor.Package:SecondFakeProjection') - ); - - // should be empty as we need an exact once delivery - self::assertEmpty( - $this->secondFakeProjection->getState()->findAppliedSequenceNumbers() - ); - - // - // fix projection and catchup - // - - $this->secondFakeProjection->killSaboteur(); - - // reactivate and catchup - $result = $this->subscriptionEngine->reactivate(SubscriptionEngineCriteria::create([SubscriptionId::fromString('Vendor.Package:SecondFakeProjection')])); - self::assertNull($result->errors); - - $this->expectOkayStatus('Vendor.Package:SecondFakeProjection', SubscriptionStatus::ACTIVE, SequenceNumber::fromInteger(1)); - self::assertEquals( - [SequenceNumber::fromInteger(1)], - $this->secondFakeProjection->getState()->findAppliedSequenceNumbers() - ); - } - - /** @test */ - public function projectionIsRolledBackAfterErrorButKeepsSuccessFullEvents() - { - $this->eventStore->setup(); - $this->fakeProjection->expects(self::once())->method('setUp'); - $this->fakeProjection->expects(self::exactly(2))->method('apply'); - $this->subscriptionEngine->setup(); - $this->subscriptionEngine->boot(); - - // commit two events - $this->commitExampleContentStreamEvent(); - $this->commitExampleContentStreamEvent(); - - $exception = new \RuntimeException('Event 2 is kaputt.'); - - // fail at the second event - $this->secondFakeProjection->injectSaboteur( - fn (EventEnvelope $eventEnvelope) => - $eventEnvelope->sequenceNumber->value === 2 - ? throw $exception - : null - ); - - self::assertEmpty( - $this->secondFakeProjection->getState()->findAppliedSequenceNumbers() - ); - - $result = $this->subscriptionEngine->catchUpActive(); - self::assertTrue($result->hasFailed()); - - $expectedFailure = ProjectionSubscriptionStatus::create( - subscriptionId: SubscriptionId::fromString('Vendor.Package:SecondFakeProjection'), - subscriptionStatus: SubscriptionStatus::ERROR, - subscriptionPosition: SequenceNumber::fromInteger(1), - subscriptionError: SubscriptionError::fromPreviousStatusAndException(SubscriptionStatus::ACTIVE, $exception), - setupStatus: ProjectionStatus::ok(), - ); - - self::assertEquals( - $expectedFailure, - $this->subscriptionStatus('Vendor.Package:SecondFakeProjection') - ); - - // the first successful event is applied and committet: - self::assertEquals( - [SequenceNumber::fromInteger(1)], - $this->secondFakeProjection->getState()->findAppliedSequenceNumbers() - ); - - // - // fix projection and catchup - // - - $this->secondFakeProjection->killSaboteur(); - - // catchup after fix - $result = $this->subscriptionEngine->reactivate(SubscriptionEngineCriteria::create([SubscriptionId::fromString('Vendor.Package:SecondFakeProjection')])); - self::assertNull($result->errors); - - // subscriptionError is reset, and the position is advanced if there were events - $this->expectOkayStatus('Vendor.Package:SecondFakeProjection', SubscriptionStatus::ACTIVE, SequenceNumber::fromInteger(2)); - self::assertEquals( - [SequenceNumber::fromInteger(1), SequenceNumber::fromInteger(2)], - $this->secondFakeProjection->getState()->findAppliedSequenceNumbers() - ); - } - /** @test */ public function projectionErrorWithMultipleProjectionsInContentRepositoryHandle() { diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/ProjectionRollbackTestTrait.php b/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/ProjectionRollbackTestTrait.php new file mode 100644 index 0000000000..835173717f --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Functional/Subscription/ProjectionRollbackTestTrait.php @@ -0,0 +1,144 @@ +eventStore->setup(); + $this->fakeProjection->expects(self::once())->method('setUp'); + $this->fakeProjection->expects(self::once())->method('apply'); + $result = $this->subscriptionEngine->setup(); + self::assertNull($result->errors); + $result = $this->subscriptionEngine->boot(); + self::assertNull($result->errors); + + // commit an event + $this->commitExampleContentStreamEvent(); + + $exception = new \RuntimeException('This projection is kaputt.'); + + $this->secondFakeProjection->injectSaboteur(fn () => throw $exception); + + $expectedFailure = ProjectionSubscriptionStatus::create( + subscriptionId: SubscriptionId::fromString('Vendor.Package:SecondFakeProjection'), + subscriptionStatus: SubscriptionStatus::ERROR, + subscriptionPosition: SequenceNumber::none(), + subscriptionError: SubscriptionError::fromPreviousStatusAndException(SubscriptionStatus::ACTIVE, $exception), + setupStatus: ProjectionStatus::ok(), + ); + + self::assertEmpty( + $this->secondFakeProjection->getState()->findAppliedSequenceNumbers() + ); + + $result = $this->subscriptionEngine->catchUpActive(); + self::assertSame($result->errors?->first()->message, 'This projection is kaputt.'); + + self::assertEquals( + $expectedFailure, + $this->subscriptionStatus('Vendor.Package:SecondFakeProjection') + ); + + // should be empty as we need an exact once delivery + self::assertEmpty( + $this->secondFakeProjection->getState()->findAppliedSequenceNumbers() + ); + + // + // fix projection and catchup + // + + $this->secondFakeProjection->killSaboteur(); + + // reactivate and catchup + $result = $this->subscriptionEngine->reactivate(SubscriptionEngineCriteria::create([SubscriptionId::fromString('Vendor.Package:SecondFakeProjection')])); + self::assertNull($result->errors); + + $this->expectOkayStatus('Vendor.Package:SecondFakeProjection', SubscriptionStatus::ACTIVE, SequenceNumber::fromInteger(1)); + self::assertEquals( + [SequenceNumber::fromInteger(1)], + $this->secondFakeProjection->getState()->findAppliedSequenceNumbers() + ); + } + + /** @test */ + public function projectionIsRolledBackAfterErrorButKeepsSuccessFullEvents() + { + $this->eventStore->setup(); + $this->fakeProjection->expects(self::once())->method('setUp'); + $this->fakeProjection->expects(self::exactly(2))->method('apply'); + $this->subscriptionEngine->setup(); + $this->subscriptionEngine->boot(); + + // commit two events + $this->commitExampleContentStreamEvent(); + $this->commitExampleContentStreamEvent(); + + $exception = new \RuntimeException('Event 2 is kaputt.'); + + // fail at the second event + $this->secondFakeProjection->injectSaboteur( + fn (EventEnvelope $eventEnvelope) => + $eventEnvelope->sequenceNumber->value === 2 + ? throw $exception + : null + ); + + self::assertEmpty( + $this->secondFakeProjection->getState()->findAppliedSequenceNumbers() + ); + + $result = $this->subscriptionEngine->catchUpActive(); + self::assertTrue($result->hasFailed()); + + $expectedFailure = ProjectionSubscriptionStatus::create( + subscriptionId: SubscriptionId::fromString('Vendor.Package:SecondFakeProjection'), + subscriptionStatus: SubscriptionStatus::ERROR, + subscriptionPosition: SequenceNumber::fromInteger(1), + subscriptionError: SubscriptionError::fromPreviousStatusAndException(SubscriptionStatus::ACTIVE, $exception), + setupStatus: ProjectionStatus::ok(), + ); + + self::assertEquals( + $expectedFailure, + $this->subscriptionStatus('Vendor.Package:SecondFakeProjection') + ); + + // the first successful event is applied and committet: + self::assertEquals( + [SequenceNumber::fromInteger(1)], + $this->secondFakeProjection->getState()->findAppliedSequenceNumbers() + ); + + // + // fix projection and catchup + // + + $this->secondFakeProjection->killSaboteur(); + + // catchup after fix + $result = $this->subscriptionEngine->reactivate(SubscriptionEngineCriteria::create([SubscriptionId::fromString('Vendor.Package:SecondFakeProjection')])); + self::assertNull($result->errors); + + // subscriptionError is reset, and the position is advanced if there were events + $this->expectOkayStatus('Vendor.Package:SecondFakeProjection', SubscriptionStatus::ACTIVE, SequenceNumber::fromInteger(2)); + self::assertEquals( + [SequenceNumber::fromInteger(1), SequenceNumber::fromInteger(2)], + $this->secondFakeProjection->getState()->findAppliedSequenceNumbers() + ); + } +}