diff --git a/phpunit.xml.dist b/phpunit.xml.dist index eff8e8b..a86a76d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,13 +1,17 @@ + + ./tests/Unit + ./tests/Functional diff --git a/src/Decorators/SymfonySession.php b/src/Decorators/SymfonySession.php index ae2278b..6a06216 100644 --- a/src/Decorators/SymfonySession.php +++ b/src/Decorators/SymfonySession.php @@ -3,24 +3,33 @@ namespace Neo4j\Neo4jBundle\Decorators; use Laudis\Neo4j\Basic\Session; -use Laudis\Neo4j\Common\TransactionHelper; +use Laudis\Neo4j\Contracts\ConnectionPoolInterface; +use Laudis\Neo4j\Contracts\CypherSequence; use Laudis\Neo4j\Contracts\SessionInterface; use Laudis\Neo4j\Databags\Bookmark; +use Laudis\Neo4j\Databags\SessionConfiguration; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Databags\SummarizedResult; use Laudis\Neo4j\Databags\TransactionConfiguration; +use Laudis\Neo4j\Enum\AccessMode; +use Laudis\Neo4j\Exception\Neo4jException; use Laudis\Neo4j\Types\CypherList; use Neo4j\Neo4jBundle\EventHandler; use Neo4j\Neo4jBundle\Factories\SymfonyDriverFactory; final class SymfonySession implements SessionInterface { + private const MAX_RETRIES = 3; + private const ROLLBACK_CLASSIFICATIONS = ['ClientError', 'TransientError', 'DatabaseError']; + public function __construct( private readonly Session $session, private readonly EventHandler $handler, private readonly SymfonyDriverFactory $factory, private readonly string $alias, private readonly string $schema, + private readonly SessionConfiguration $config, + private readonly ConnectionPoolInterface $pool, ) { } @@ -76,10 +85,7 @@ public function beginTransaction(?iterable $statements = null, ?TransactionConfi #[\Override] public function writeTransaction(callable $tsxHandler, ?TransactionConfiguration $config = null) { - return TransactionHelper::retry( - fn () => $this->beginTransaction(config: $config), - $tsxHandler - ); + return $this->retryTransaction($tsxHandler, $config, read: false); } /** @@ -92,8 +98,7 @@ public function writeTransaction(callable $tsxHandler, ?TransactionConfiguration #[\Override] public function readTransaction(callable $tsxHandler, ?TransactionConfiguration $config = null) { - // TODO: create read transaction here. - return $this->writeTransaction($tsxHandler, $config); + return $this->retryTransaction($tsxHandler, $config, read: true); } /** @@ -114,4 +119,68 @@ public function getLastBookmark(): Bookmark { return $this->session->getLastBookmark(); } + + /** + * Custom retry transaction logic to replace TransactionHelper. + * + * @template HandlerResult + * + * @param callable(SymfonyTransaction):HandlerResult $tsxHandler + * + * @return HandlerResult + */ + private function retryTransaction(callable $tsxHandler, ?TransactionConfiguration $config, bool $read) + { + $attempt = 0; + + while (true) { + ++$attempt; + $transaction = null; + + try { + $sessionConfig = $this->config->withAccessMode($read ? AccessMode::READ() : AccessMode::WRITE()); + $transaction = $this->startTransaction($config, $sessionConfig); + + $result = $tsxHandler($transaction); + + self::triggerLazyResult($result); + $transaction->commit(); + + return $result; + } catch (Neo4jException $e) { + if ($transaction && !in_array($e->getClassification(), self::ROLLBACK_CLASSIFICATIONS)) { + $transaction->rollback(); + } + + if ('NotALeader' === $e->getTitle()) { + $this->pool->close(); + } elseif ('TransientError' !== $e->getClassification()) { + throw $e; + } + + if ($attempt >= self::MAX_RETRIES) { + throw $e; + } + + usleep(100_000); + } + } + } + + private static function triggerLazyResult(mixed $tbr): void + { + if ($tbr instanceof CypherSequence) { + $tbr->preload(); + } + } + + private function startTransaction(?TransactionConfiguration $config, SessionConfiguration $sessionConfig): SymfonyTransaction + { + return $this->factory->createTransaction( + session: $this->session, + config: $config, + alias: $this->alias, + schema: $this->schema + ); + } } diff --git a/src/DependencyInjection/Neo4jExtension.php b/src/DependencyInjection/Neo4jExtension.php index 5531036..94e7626 100644 --- a/src/DependencyInjection/Neo4jExtension.php +++ b/src/DependencyInjection/Neo4jExtension.php @@ -11,7 +11,6 @@ use Neo4j\Neo4jBundle\EventHandler; use Neo4j\Neo4jBundle\EventListener\Neo4jProfileListener; use Psr\Log\LoggerInterface; -use Psr\Log\LogLevel; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; diff --git a/src/Factories/SymfonyDriverFactory.php b/src/Factories/SymfonyDriverFactory.php index 0650912..88d8c1c 100644 --- a/src/Factories/SymfonyDriverFactory.php +++ b/src/Factories/SymfonyDriverFactory.php @@ -54,6 +54,8 @@ public function createSession( factory: $this, alias: $alias, schema: $schema, + config: $config ?? new SessionConfiguration(), + pool: $this->getPoolFromDriver($driver), ); } @@ -70,6 +72,21 @@ public function createDriver( ); } + private function getPoolFromDriver(Driver $driver): \Laudis\Neo4j\Contracts\ConnectionPoolInterface + { + // Use reflection to access the private pool property from the underlying driver + $reflection = new \ReflectionClass($driver); + $driverProperty = $reflection->getProperty('driver'); + $driverProperty->setAccessible(true); + $underlyingDriver = $driverProperty->getValue($driver); + + $underlyingReflection = new \ReflectionClass($underlyingDriver); + $poolProperty = $underlyingReflection->getProperty('pool'); + $poolProperty->setAccessible(true); + + return $poolProperty->getValue($underlyingDriver); + } + private function generateTransactionId(): string { if ($this->uuidFactory) { diff --git a/tests/App/Controller/TestController.php b/tests/App/Controller/TestController.php index f77be3a..294f13e 100644 --- a/tests/App/Controller/TestController.php +++ b/tests/App/Controller/TestController.php @@ -14,6 +14,11 @@ public function __construct(private readonly LoggerInterface $logger) { } + public function index(): Response + { + return $this->render('index.html.twig'); + } + public function runOnClient(ClientInterface $client): Response { $client->run('MATCH (n {foo: $bar}) RETURN n', ['bar' => 'baz']); diff --git a/tests/App/config/routes.yaml b/tests/App/config/routes.yaml index ec58a78..8846a68 100644 --- a/tests/App/config/routes.yaml +++ b/tests/App/config/routes.yaml @@ -1,3 +1,6 @@ +index: + path: / + controller: Neo4j\Neo4jBundle\Tests\App\Controller\TestController::index run-on-client: path: /client controller: Neo4j\Neo4jBundle\Tests\App\Controller\TestController::runOnClient @@ -9,8 +12,8 @@ run-on-transaction: controller: Neo4j\Neo4jBundle\Tests\App\Controller\TestController::runOnTransaction web_profiler_wdt: - resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' + resource: '@WebProfilerBundle/Resources/config/routing/wdt.php' prefix: /_wdt web_profiler_profiler: - resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' + resource: '@WebProfilerBundle/Resources/config/routing/profiler.php' prefix: /_profiler4 \ No newline at end of file diff --git a/tests/Functional/ProfilerTest.php b/tests/Functional/ProfilerTest.php index 78b00ea..015b3c6 100644 --- a/tests/Functional/ProfilerTest.php +++ b/tests/Functional/ProfilerTest.php @@ -1,79 +1,314 @@ enableProfiler(); + // Use the same hostname that the Symfony application uses + // In Docker environments, this is typically 'neo4j' + $host = $_ENV['NEO4J_HOST'] ?? 'neo4j'; + $port = $_ENV['NEO4J_PORT'] ?? '7687'; + + // Create a simple TCP connection test + $socket = @fsockopen($host, (int) $port, $errno, $errstr, 5); + if (!$socket) { + $this->markTestSkipped( + 'Neo4j server is not available for testing. '. + 'Please start a Neo4j server to run profiler tests. '. + "Error: Cannot connect to $host:$port - $errstr ($errno)" + ); + } + fclose($socket); + + // Additional check: Try to make a simple HTTP request to Neo4j's web interface + $httpPort = $_ENV['NEO4J_HTTP_PORT'] ?? '7474'; + $context = stream_context_create([ + 'http' => [ + 'timeout' => 5, + 'method' => 'GET', + ], + ]); - $client->request('GET', '/client'); + $httpResponse = @file_get_contents("http://$host:$httpPort", false, $context); + if (false === $httpResponse) { + $this->markTestSkipped( + 'Neo4j server is not fully available for testing. '. + 'Please start a Neo4j server to run profiler tests. '. + "Error: Cannot connect to Neo4j HTTP interface at $host:$httpPort" + ); + } + + // Final check: Try to create a minimal Neo4j client connection + try { + $user = $_ENV['NEO4J_USER'] ?? 'neo4j'; + $password = $_ENV['NEO4J_PASSWORD'] ?? 'testtest'; + + $client = \Laudis\Neo4j\ClientBuilder::create() + ->withDriver('default', "bolt://$user:$password@$host:$port") + ->build(); - if ($profile = $client->getProfile()) { - /** @var Neo4jDataCollector $collector */ - $collector = $profile->getCollector('neo4j'); - $this->assertEquals( - 2, - $collector->getQueryCount() + // Try a simple query to verify the connection works + $result = $client->run('RETURN 1 as test'); + } catch (\Exception $e) { + $this->markTestSkipped( + 'Neo4j server is not properly configured for testing. '. + 'Please start a Neo4j server to run profiler tests. '. + 'Error: '.$e->getMessage() ); - $successfulStatements = $collector->getSuccessfulStatements(); - $failedStatements = $collector->getFailedStatements(); - $this->assertCount(1, $successfulStatements); - $this->assertCount(1, $failedStatements); } } + /** + * Test profiler data collection for client-level operations. + * + * This test verifies that the profiler correctly collects: + * - Total query count + * - Successful statements + * - Failed statements + * - Execution timing data + */ + public function testProfilerOnClient(): void + { + $this->skipIfNeo4jNotAvailable(); + + $client = static::createClient(); + $client->enableProfiler(); + + $crawler = $client->request('GET', '/client'); + + $this->assertEquals(Response::HTTP_OK, $client->getResponse()->getStatusCode()); + + $profile = $client->getProfile(); + $this->assertNotNull($profile, 'Profiler should be enabled and available'); + $this->assertNotFalse($profile, 'Profiler should be enabled and available'); + + /** @var Neo4jDataCollector $collector */ + $collector = $profile->getCollector('neo4j'); + $this->assertInstanceOf(Neo4jDataCollector::class, $collector); + + $this->assertEquals( + self::EXPECTED_QUERY_COUNT, + $collector->getQueryCount(), + 'Expected exactly 2 queries: 1 successful, 1 failed' + ); + + $successfulStatements = $collector->getSuccessfulStatements(); + $this->assertCount( + self::EXPECTED_SUCCESSFUL_STATEMENTS, + $successfulStatements, + 'Expected exactly 1 successful statement' + ); + + $failedStatements = $collector->getFailedStatements(); + $this->assertCount( + self::EXPECTED_FAILED_STATEMENTS, + $failedStatements, + 'Expected exactly 1 failed statement' + ); + + $this->assertNotEmpty($successfulStatements, 'Successful statements should not be empty'); + $this->assertNotEmpty($failedStatements, 'Failed statements should not be empty'); + + $successfulStatement = $successfulStatements[0]; + $this->assertArrayHasKey('statement', $successfulStatement); + $this->assertArrayHasKey('parameters', $successfulStatement); + $this->assertArrayHasKey('time', $successfulStatement); + + $failedStatement = $failedStatements[0]; + $this->assertArrayHasKey('statement', $failedStatement); + $this->assertArrayHasKey('parameters', $failedStatement); + $this->assertArrayHasKey('time', $failedStatement); + $this->assertArrayHasKey('error', $failedStatement); + } + + /** + * Test profiler data collection for session-level operations. + * + * This test verifies that the profiler correctly collects data + * when using session-level Neo4j operations. + */ public function testProfilerOnSession(): void { + $this->skipIfNeo4jNotAvailable(); + $client = static::createClient(); $client->enableProfiler(); - $client->request('GET', '/session'); + $crawler = $client->request('GET', '/session'); - if ($profile = $client->getProfile()) { - /** @var Neo4jDataCollector $collector */ - $collector = $profile->getCollector('neo4j'); - $this->assertEquals( - 2, - $collector->getQueryCount() - ); - $successfulStatements = $collector->getSuccessfulStatements(); - $failedStatements = $collector->getFailedStatements(); - $this->assertCount(1, $successfulStatements); - $this->assertCount(1, $failedStatements); + $this->assertEquals(Response::HTTP_OK, $client->getResponse()->getStatusCode()); + + $profile = $client->getProfile(); + $this->assertNotNull($profile, 'Profiler should be enabled and available'); + $this->assertNotFalse($profile, 'Profiler should be enabled and available'); + + /** @var Neo4jDataCollector $collector */ + $collector = $profile->getCollector('neo4j'); + $this->assertInstanceOf(Neo4jDataCollector::class, $collector); + + $this->assertEquals( + self::EXPECTED_QUERY_COUNT, + $collector->getQueryCount(), + 'Expected exactly 2 queries: 1 successful, 1 failed' + ); + + $successfulStatements = $collector->getSuccessfulStatements(); + $this->assertCount( + self::EXPECTED_SUCCESSFUL_STATEMENTS, + $successfulStatements, + 'Expected exactly 1 successful statement' + ); + + $failedStatements = $collector->getFailedStatements(); + $this->assertCount( + self::EXPECTED_FAILED_STATEMENTS, + $failedStatements, + 'Expected exactly 1 failed statement' + ); + + $successfulStatements = $collector->getSuccessfulStatements(); + if (!empty($successfulStatements)) { + $this->assertArrayHasKey('time', $successfulStatements[0], 'Successful statements should contain timing information'); } } + /** + * Test profiler data collection for transaction-level operations. + * + * This test verifies that the profiler correctly collects data + * when using transaction-level Neo4j operations. + */ public function testProfilerOnTransaction(): void { + $this->skipIfNeo4jNotAvailable(); + $client = static::createClient(); $client->enableProfiler(); - $client->request('GET', '/transaction'); + $crawler = $client->request('GET', '/transaction'); - if ($profile = $client->getProfile()) { - /** @var Neo4jDataCollector $collector */ - $collector = $profile->getCollector('neo4j'); - $this->assertEquals( - 2, - $collector->getQueryCount() - ); - $successfulStatements = $collector->getSuccessfulStatements(); - $failedStatements = $collector->getFailedStatements(); - $this->assertCount(1, $successfulStatements); - $this->assertCount(1, $failedStatements); + $this->assertEquals(Response::HTTP_OK, $client->getResponse()->getStatusCode()); + + $profile = $client->getProfile(); + $this->assertNotNull($profile, 'Profiler should be enabled and available'); + $this->assertNotFalse($profile, 'Profiler should be enabled and available'); + + /** @var Neo4jDataCollector $collector */ + $collector = $profile->getCollector('neo4j'); + $this->assertInstanceOf(Neo4jDataCollector::class, $collector); + + $this->assertEquals( + self::EXPECTED_QUERY_COUNT, + $collector->getQueryCount(), + 'Expected exactly 2 queries: 1 successful, 1 failed' + ); + + $successfulStatements = $collector->getSuccessfulStatements(); + $this->assertCount( + self::EXPECTED_SUCCESSFUL_STATEMENTS, + $successfulStatements, + 'Expected exactly 1 successful statement' + ); + + $failedStatements = $collector->getFailedStatements(); + $this->assertCount( + self::EXPECTED_FAILED_STATEMENTS, + $failedStatements, + 'Expected exactly 1 failed statement' + ); + + $successfulStatements = $collector->getSuccessfulStatements(); + if (!empty($successfulStatements)) { + $this->assertArrayHasKey('time', $successfulStatements[0], 'Transaction statements should contain timing information'); } } + + /** + * Test profiler data collector availability and basic functionality. + * + * This test verifies that the Neo4j data collector is properly registered + * and returns expected default values when no queries have been executed. + */ + public function testProfilerDataCollectorAvailability(): void + { + $client = static::createClient(); + $client->enableProfiler(); + + $crawler = $client->request('GET', '/'); + + $profile = $client->getProfile(); + $this->assertNotNull($profile, 'Profiler should be enabled and available'); + $this->assertNotFalse($profile, 'Profiler should be enabled and available'); + + /** @var Neo4jDataCollector $collector */ + $collector = $profile->getCollector('neo4j'); + $this->assertInstanceOf(Neo4jDataCollector::class, $collector); + + $this->assertEquals(0, $collector->getQueryCount(), 'Query count should be 0 when no queries executed'); + $this->assertCount(0, $collector->getSuccessfulStatements(), 'No successful statements expected'); + $this->assertCount(0, $collector->getFailedStatements(), 'No failed statements expected'); + } + + /** + * Test profiler data collector name and toolbar integration. + * + * This test verifies that the data collector is properly configured + * for integration with the Symfony profiler toolbar. + */ + public function testProfilerDataCollectorConfiguration(): void + { + $client = static::createClient(); + $client->enableProfiler(); + + $crawler = $client->request('GET', '/'); + + $profile = $client->getProfile(); + $this->assertNotNull($profile); + $this->assertNotFalse($profile); + + /** @var Neo4jDataCollector $collector */ + $collector = $profile->getCollector('neo4j'); + $this->assertInstanceOf(Neo4jDataCollector::class, $collector); + + $this->assertEquals('neo4j', $collector->getName(), 'Collector name should be "neo4j"'); + + $serialized = serialize($collector); + + $unserialized = unserialize($serialized); + $this->assertInstanceOf(Neo4jDataCollector::class, $unserialized); + $this->assertEquals($collector->getQueryCount(), $unserialized->getQueryCount()); + } } diff --git a/tests/Unit/SymfonySessionWriteTransactionTest.php b/tests/Unit/SymfonySessionWriteTransactionTest.php new file mode 100644 index 0000000..c2dc229 --- /dev/null +++ b/tests/Unit/SymfonySessionWriteTransactionTest.php @@ -0,0 +1,264 @@ +poolMock = $this->createMock(ConnectionPoolInterface::class); + + $this->sessionMock = new Session( + session: $this->createMock(\Laudis\Neo4j\Contracts\SessionInterface::class) + ); + + $this->eventHandlerMock = new EventHandler( + dispatcher: null, + stopwatch: null, + nameFactory: new StopwatchEventNameFactory() + ); + + $this->factoryMock = new SymfonyDriverFactory( + handler: $this->eventHandlerMock, + uuidFactory: null + ); + + $sessionConfig = new SessionConfiguration(); + $sessionConfig = $sessionConfig->withAccessMode(AccessMode::WRITE()); + + $this->symfonySession = new SymfonySession( + session: $this->sessionMock, + handler: $this->eventHandlerMock, + factory: $this->factoryMock, + alias: 'test-alias', + schema: 'neo4j', + config: $sessionConfig, + pool: $this->poolMock + ); + } + + /** + * Call the private retryTransaction method using reflection. + * + * @template HandlerResult + * + * @param callable(SymfonyTransaction):HandlerResult $tsxHandler + * + * @return HandlerResult + */ + private function callRetryTransaction(callable $tsxHandler, bool $read = false) + { + $reflection = new \ReflectionClass($this->symfonySession); + $method = $reflection->getMethod('retryTransaction'); + $method->setAccessible(true); + + return $method->invoke($this->symfonySession, $tsxHandler, null, $read); + } + + public function testRetryTransactionSuccess(): void + { + $expectedResult = 'transaction-result'; + + $tsxHandler = fn (SymfonyTransaction $tx) => $expectedResult; + + $result = $this->callRetryTransaction($tsxHandler); + + $this->assertEquals($expectedResult, $result); + } + + public function testRetryTransactionWithCypherSequenceResult(): void + { + $cypherSequenceMock = $this->createMock(CypherSequence::class); + $cypherSequenceMock->expects($this->once()) + ->method('preload'); + + $tsxHandler = fn (SymfonyTransaction $tx) => $cypherSequenceMock; + + $result = $this->callRetryTransaction($tsxHandler); + + $this->assertSame($cypherSequenceMock, $result); + } + + public function testRetryTransactionRetryOnTransientError(): void + { + $expectedResult = 'transaction-result'; + $transientException = new Neo4jException([new Neo4jError('TransientError', 'Transient error', 'TransientError', 'Transient', 'TransientError')]); + + $callCount = 0; + $tsxHandler = function (SymfonyTransaction $tx) use (&$callCount, $transientException, $expectedResult) { + ++$callCount; + if (1 === $callCount) { + throw $transientException; + } + + return $expectedResult; + }; + + $result = $this->callRetryTransaction($tsxHandler); + + $this->assertEquals($expectedResult, $result); + $this->assertEquals(2, $callCount); + } + + public function testRetryTransactionMaxRetriesExceeded(): void + { + $transientException = new Neo4jException([new Neo4jError('TransientError', 'Transient error', 'TransientError', 'Transient', 'TransientError')]); + + $tsxHandler = fn (SymfonyTransaction $tx) => throw $transientException; + + $this->expectException(Neo4jException::class); + $this->expectExceptionMessage('Transient error'); + + $this->callRetryTransaction($tsxHandler); + } + + public function testRetryTransactionNotALeaderErrorClosesPool(): void + { + $notALeaderException = new Neo4jException([new Neo4jError('ClientError', 'Not a leader', 'ClientError', 'Client', 'NotALeader')]); + + $this->poolMock->expects($this->atLeastOnce()) + ->method('close'); + + $tsxHandler = fn (SymfonyTransaction $tx) => throw $notALeaderException; + + $this->expectException(Neo4jException::class); + $this->expectExceptionMessage('Not a leader'); + + $this->callRetryTransaction($tsxHandler); + } + + public function testRetryTransactionNonTransientErrorThrownImmediately(): void + { + $clientError = new Neo4jException([new Neo4jError('ClientError', 'Client error', 'ClientError', 'Client', 'ClientError')]); + + $tsxHandler = fn (SymfonyTransaction $tx) => throw $clientError; + + $this->expectException(Neo4jException::class); + $this->expectExceptionMessage('Client error'); + + $this->callRetryTransaction($tsxHandler); + } + + public function testRetryTransactionDatabaseErrorThrownImmediately(): void + { + $databaseError = new Neo4jException([new Neo4jError('DatabaseError', 'Database error', 'DatabaseError', 'Database', 'DatabaseError')]); + + $tsxHandler = fn (SymfonyTransaction $tx) => throw $databaseError; + + $this->expectException(Neo4jException::class); + $this->expectExceptionMessage('Database error'); + + $this->callRetryTransaction($tsxHandler); + } + + public function testRetryTransactionNoRollbackForNonRollbackClassifications(): void + { + $nonRollbackException = new Neo4jException([new Neo4jError('UnknownError', 'Non-rollback error', 'UnknownError', 'Unknown', 'UnknownError')]); + + $tsxHandler = fn (SymfonyTransaction $tx) => throw $nonRollbackException; + + $this->expectException(Neo4jException::class); + $this->expectExceptionMessage('Non-rollback error'); + + $this->callRetryTransaction($tsxHandler); + } + + public function testRetryTransactionMultipleTransientErrors(): void + { + $expectedResult = 'transaction-result'; + $transientException = new Neo4jException([new Neo4jError('TransientError', 'Transient error', 'TransientError', 'Transient', 'TransientError')]); + + $callCount = 0; + $tsxHandler = function (SymfonyTransaction $tx) use (&$callCount, $transientException, $expectedResult) { + ++$callCount; + if ($callCount <= 2) { + throw $transientException; + } + + return $expectedResult; + }; + + $result = $this->callRetryTransaction($tsxHandler); + + $this->assertEquals($expectedResult, $result); + $this->assertEquals(3, $callCount); + } + + public function testRetryTransactionRetryDelay(): void + { + $transientException = new Neo4jException([new Neo4jError('TransientError', 'Transient error', 'TransientError', 'Transient', 'TransientError')]); + + $callCount = 0; + $tsxHandler = function (SymfonyTransaction $tx) use (&$callCount, $transientException) { + ++$callCount; + if (1 === $callCount) { + throw $transientException; + } + + return 'success'; + }; + + $startTime = microtime(true); + + $result = $this->callRetryTransaction($tsxHandler); + + $endTime = microtime(true); + $duration = $endTime - $startTime; + + $this->assertEquals('success', $result); + $this->assertGreaterThan(0.1, $duration); + } + + public function testRetryTransactionWithReadTransaction(): void + { + $expectedResult = 'read-transaction-result'; + + $tsxHandler = fn (SymfonyTransaction $tx) => $expectedResult; + + $result = $this->callRetryTransaction($tsxHandler, read: true); + + $this->assertEquals($expectedResult, $result); + } + + public function testRetryTransactionConstants(): void + { + $reflection = new \ReflectionClass($this->symfonySession); + + $maxRetries = $reflection->getConstant('MAX_RETRIES'); + $rollbackClassifications = $reflection->getConstant('ROLLBACK_CLASSIFICATIONS'); + + $this->assertEquals(3, $maxRetries); + $this->assertEquals(['ClientError', 'TransientError', 'DatabaseError'], $rollbackClassifications); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..4038072 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,6 @@ +