['annotation' => 'requiresRedisJsonVersion', 'name' => 'ReJSON'], 'bloomFilter' => ['annotation' => 'requiresRedisBfVersion', 'name' => 'bf'], 'search' => ['annotation' => 'requiresRediSearchVersion', 'name' => 'search'], 'timeSeries' => ['annotation' => 'requiresRedisTimeSeriesVersion', 'name' => 'timeseries'], ]; /** * Info of current Redis instance. * * @var array */ private $info; /** * {@inheritdoc} */ protected function setUp(): void { $this->checkRequiredRedisServerVersion(); foreach ($this->modulesMapping as $module => $config) { $this->checkRequiredRedisModuleVersion($module); } } /** * Pauses the test case for the specified amount of time in seconds. * * @param float $seconds Seconds to sleep */ protected function sleep(float $seconds): void { usleep($seconds * 1000000); } /** * Verifies that a Redis command is a valid Predis\Command\CommandInterface * instance with the specified ID and command arguments. * * @param Command\CommandInterface|string $command Expected command instance or command ID * @param ?array $arguments Expected command arguments * * @return RedisCommandConstraint */ public function isRedisCommand($command = null, ?array $arguments = null): RedisCommandConstraint { return new RedisCommandConstraint($command, $arguments); } /** * Ensures that two Redis commands are similar. * * This method supports can test for different constraints by accepting a few * combinations of values as indicated below: * * - a string identifying a Redis command by its ID * - an instance of Predis\Command\CommandInterface * - an array of [(string) $commandID, (array) $commandArguments] * * Internally this method uses the RedisCommandConstraint class. * * @param Command\CommandInterface|string|array $expected Expected command instance or command ID * @param mixed $actual Actual command * @param string $message Optional assertion message */ public function assertRedisCommand($expected, $actual, string $message = ''): void { if (is_array($expected)) { @[$command, $arguments] = $expected; } else { $command = $expected; $arguments = null; } $this->assertThat($actual, new RedisCommandConstraint($command, $arguments), $message); } /** * Asserts that two arrays have the same values (even with different order). * * @param array $expected Expected array * @param array $actual Actual array * @param string $message Optional assertion message */ public function assertSameValues(array $expected, array $actual, $message = ''): void { $this->assertThat($actual, new ArrayHasSameValuesConstraint($expected), $message); } /** * Asserts that actual value is one of the values from expected array. * * @param mixed $expected Expected array. * @param mixed $actual Actual value. If array given searching for any matching value between two arrays. * @param string $message Optional assertion message * @return void */ public function assertOneOf(array $expected, $actual, string $message = ''): void { $this->assertThat($actual, new OneOfConstraint($expected), $message); } /** * Asserts that two values (of the same type) have the same values with given precision. * * @param mixed $expected Expected value * @param mixed $actual Actual value * @param int $precision Precision value should be round to * @param string $message Optional assertion message * @return void */ public function assertSameWithPrecision($expected, $actual, int $precision = 0, string $message = ''): void { $this->assertThat($actual, new AssertSameWithPrecisionConstraint($expected, $precision), $message); } /** * Asserts that a string matches a given regular expression. * * @throws ExpectationFailedException * @throws SebastianBergmann\RecursionContext\InvalidArgumentException */ public static function assertMatchesRegularExpression(string $pattern, string $string, $message = ''): void { if (method_exists(get_parent_class(parent::class), __FUNCTION__)) { call_user_func([parent::class, __FUNCTION__], $pattern, $string, $message); } else { static::assertRegExp($pattern, $string, $message); } } /** * Returns a named array with default values for connection parameters. * * @return array Default connection parameters */ protected function getDefaultParametersArray(): array { if ($this->isClusterTest()) { return $this->prepareClusterEndpoints(); } if ($this->isSentinelTest()) { return $this->prepareSentinelEndpoints(); } $password = getenv('REDIS_PASSWORD') ?: constant('REDIS_PASSWORD'); if ($this->isStackTest()) { $port = getenv('REDIS_STACK_SERVER_PORT'); } elseif ($this->isUnprotectedTest()) { $port = constant('REDIS_UNPROTECTED_SERVER_PORT'); $password = ''; } elseif ($this->isSSLTest()) { $port = getenv('REDIS_SSL_PORT'); } else { $port = constant('REDIS_SERVER_PORT'); } return [ 'scheme' => $this->isSSLTest() ? 'tls' : 'tcp', 'host' => constant('REDIS_SERVER_HOST'), 'port' => $port, 'database' => constant('REDIS_SERVER_DBNUM'), 'password' => $password, ]; } /** * Returns a named array with default values for client options. * * @return array Default connection parameters */ protected function getDefaultOptionsArray(): array { return [ 'commands' => new Command\RedisFactory(), ]; } /** * Merges a named array of connection parameters with current defaults. * * @param array $additional Additional connection parameters * * @return array */ protected function getParametersArray(array $additional): array { return array_merge($this->getDefaultParametersArray(), $additional); } /** * Returns a new instance of connection parameters. * * Values in the optional $additional named array are merged with defaults. * * @param array $additional Additional connection parameters * * @return Connection\ParametersInterface */ protected function getParameters($additional = []): Connection\ParametersInterface { $parameters = array_merge($this->getDefaultParametersArray(), $additional); $parameters = new Connection\Parameters($parameters); return $parameters; } /** * Returns a new instance of command factory. * * @return Command\Factory */ protected function getCommandFactory(): Command\Factory { return new Command\RedisFactory(); } /** * Returns a new instance of Predis\Client. * * Values in the optional $parameters named array are merged with defaults. * Values in the ottional $options named array are merged with defaults. * * @param array $parameters Additional connection parameters * @param array $options Additional client options * @param bool $flushdb Flush selected database before returning the client * * @return Client */ public function createClient(?array $parameters = null, ?array $options = null, ?bool $flushdb = true): Client { $parameters = array_merge( $this->getDefaultParametersArray(), $parameters ?: [] ); $commandsFactory = $this->getCommandFactory(); if (null !== $options && array_key_exists('prefix', $options)) { $commandsFactory->setProcessor(new KeyPrefixProcessor($options['prefix'])); } $options = array_merge( ['commands' => $commandsFactory], $options ?: [], getenv('USE_RELAY') ? ['connections' => 'relay'] : [] ); if ($this->isClusterTest()) { $options = array_merge( [ 'cluster' => 'redis', ], $options ); if ($this->isSSLTest()) { $options = $options + [ 'parameters' => [ 'ssl' => [ 'cafile' => getenv('CLUSTER_CA_CERT_PATH'), 'local_cert' => getenv('CLUSTER_LOCAL_CERT_PATH'), 'local_pk' => getenv('CLUSTER_LOCAL_PK_PATH'), 'verify_peer' => true, 'verify_peer_name' => false, 'allow_self_signed' => false, ], ], ]; } } elseif ($this->isSentinelTest()) { $options = array_merge( [ 'replication' => 'sentinel', 'service' => constant('REDIS_SENTINEL_SERVICE'), ], $options ); } else { if ($this->isSSLTest()) { $parameters = $parameters + [ 'ssl' => [ 'cafile' => getenv('STANDALONE_CA_CERT_PATH'), 'local_cert' => getenv('STANDALONE_LOCAL_CERT_PATH'), 'local_pk' => getenv('STANDALONE_LOCAL_PK_PATH'), 'verify_peer' => true, 'verify_peer_name' => false, 'allow_self_signed' => false, ], ]; } } $client = new Client($parameters, $options); $client->connect(); if ($flushdb) { $client->flushdb(); } return $client; } /** * Creates a client for version checking without SSL configuration. * This is used to check Redis version before attempting SSL connection. * * @return Client */ protected function createClientForVersionCheck(): Client { // For SSL tests, temporarily override to use non-SSL configuration $isSSL = $this->isSSLTest(); $isCluster = $this->isClusterTest(); $isSentinel = $this->isSentinelTest(); if ($isSSL && $isCluster) { // For cluster SSL tests, use non-SSL cluster endpoints $endpoints = explode(',', constant('REDIS_CLUSTER_ENDPOINTS')); $parameters = array_map(static function (string $elem) { return 'tcp://' . $elem; }, $endpoints); } elseif ($isSSL) { // For standalone SSL tests, use non-SSL port $parameters = [ 'scheme' => 'tcp', 'host' => constant('REDIS_SERVER_HOST'), 'port' => constant('REDIS_SERVER_PORT'), 'database' => constant('REDIS_SERVER_DBNUM'), 'password' => getenv('REDIS_PASSWORD') ?: constant('REDIS_PASSWORD'), ]; } else { // For non-SSL tests, use default parameters $parameters = $this->getDefaultParametersArray(); } $commandsFactory = $this->getCommandFactory(); $options = array_merge( ['commands' => $commandsFactory], getenv('USE_RELAY') ? ['connections' => 'relay'] : [] ); if ($isCluster) { $options['cluster'] = 'redis'; } elseif ($isSentinel) { $options['replication'] = 'sentinel'; $options['service'] = constant('REDIS_SENTINEL_SERVICE'); } $client = new Client($parameters, $options); $client->connect(); return $client; } /** * Returns a basic mock object of a connection to a single Redis node. * * The specified target interface used for the mock object must implement * Predis\Connection\NodeConnectionInterface. * * The mock object responds to getParameters() and __toString() by returning * the default connection parameters used by Predis or a set of connection * parameters specified in the optional second argument. * * @param string $interface Fully-qualified name of the target interface * @param array|string $parameters Optional connection parameters * * @return MockObject|Connection\NodeConnectionInterface */ protected function getMockConnectionOfType(string $interface, $parameters = null) { if (!is_a($interface, '\Predis\Connection\NodeConnectionInterface', true)) { $method = __METHOD__; throw new InvalidArgumentException( "Argument `\$interface` for $method() expects a type implementing Predis\Connection\NodeConnectionInterface" ); } $connection = $this->getMockBuilder($interface)->getMock(); if ($parameters) { $parameters = Connection\Parameters::create($parameters); $hash = "{$parameters->host}:{$parameters->port}"; $connection ->expects($this->any()) ->method('getParameters') ->willReturn($parameters); $connection ->expects($this->any()) ->method('__toString') ->willReturn($hash); } return $connection; } /** * Returns a basic mock object of a connection to a single Redis node. * * The mock object is based on Predis\Connection\NodeConnectionInterface. * * The mock object responds to getParameters() and __toString() by returning * the default connection parameters used by Predis or a set of connection * parameters specified in the optional second argument. * * @param array|string|null $parameters Optional connection parameters * * @return MockObject|Connection\NodeConnectionInterface */ protected function getMockConnection($parameters = null) { return $this->getMockConnectionOfType('Predis\Connection\NodeConnectionInterface', $parameters); } /** * Returns the server version of the Redis instance used by the test suite. * * @return string * @throws RuntimeException When the client cannot retrieve the current server version */ protected function getRedisServerVersion(): string { if (isset($this->redisServerVersion)) { return $this->redisServerVersion; } if (isset($this->info)) { $info = $this->info; } else { // For SSL tests, connect to non-SSL port to check version first // This prevents connection failures on Redis < 7.2.0 which doesn't support SSL $client = $this->createClientForVersionCheck(); $info = array_change_key_case($client->info()); $this->info = $info; } if (isset($info['server']['redis_version'])) { // Redis >= 2.6 $version = $info['server']['redis_version']; } elseif (isset($info['redis_version'])) { // Redis < 2.6 $version = $info['redis_version']; } else { $client = $this->createClientForVersionCheck(); $connection = $client->getConnection(); throw new RuntimeException("Unable to retrieve a valid server info payload from $connection"); } $this->redisServerVersion = $version; return $version; } /** * Returns the Redis server version required to run a @connected test. * * This value is retrieved from the @requiresRedisVersion annotation that * decorates the target test method. * * @return string */ protected function getRequiredRedisServerVersion(): ?string { $annotations = TestUtil::parseTestMethodAnnotations( get_class($this), $this->getName(false) ); if (isset($annotations['method']['requiresRedisVersion'], $annotations['method']['group']) && !empty($annotations['method']['requiresRedisVersion']) && in_array('connected', $annotations['method']['group']) ) { return $annotations['method']['requiresRedisVersion'][0]; } return null; } /** * Compares the specified version string against the Redis server version in * use for integration tests. * * @param string $operator Comparison operator * @param string $version Version to compare * * @return bool */ public function isRedisServerVersion(string $operator, string $version): bool { $serverVersion = $this->getRedisServerVersion(); $comparison = version_compare($serverVersion, $version); return (bool) eval("return $comparison $operator 0;"); } /** * Ensures the current Redis server matches version requirements for tests. * * Requirements are retrieved from the @requiresRedisVersion annotation that * decorates test methods while the version of the Redis server used to run * integration tests is retrieved directly from the server by using `INFO`. * * @throws PHPUnit\Framework\SkippedTestError When the required Redis server version is not met */ protected function checkRequiredRedisServerVersion(): void { if (!$requiredVersion = $this->getRequiredRedisServerVersion()) { return; } $requiredVersion = explode(' ', $requiredVersion, 2); if (count($requiredVersion) === 1) { $reqOperator = '>='; $reqVersion = $requiredVersion[0]; } else { $reqOperator = $requiredVersion[0]; $reqVersion = $requiredVersion[1]; } if (!$this->isRedisServerVersion($reqOperator, $reqVersion)) { $serverVersion = $this->getRedisServerVersion(); $this->markTestSkipped( "Test requires a Redis server instance $reqOperator $reqVersion but target server is $serverVersion" ); } } /** * Ensures the current Redis JSON module matches version requirements for tests. * * @param string $module * @return void */ protected function checkRequiredRedisModuleVersion(string $module): void { if (null === $requiredVersion = $this->getRequiredModuleVersion($module)) { return; } if (version_compare($this->getRedisServerVersion(), '6.0.0', '<')) { $this->markTestSkipped( 'Test skipped because Redis JSON module available since Redis 6.x' ); } $requiredVersion = explode(' ', $requiredVersion, 2); if (count($requiredVersion) === 1) { $reqVersion = $requiredVersion[0]; } else { $reqVersion = $requiredVersion[1]; } if (!$this->isSatisfiedRedisModuleVersion($reqVersion, $module)) { $redisModuleVersion = $this->getRedisModuleVersion($this->modulesMapping[$module]['name']); $redisModuleVersion = str_replace('0', '.', $redisModuleVersion); $this->markTestSkipped( "Test requires a Redis $module module >= $reqVersion but target module is $redisModuleVersion" ); } } /** * @param string $versionToCheck * @param string $module * @return bool */ protected function isSatisfiedRedisModuleVersion(string $versionToCheck, string $module): bool { $currentVersion = $this->getRedisModuleVersion($this->modulesMapping[$module]['name']); $versionToCheck = str_replace('.', '', $versionToCheck); return (int) $currentVersion >= (int) $versionToCheck; } /** * Returns version of Redis JSON module if it's available. * * @param string $module * @return string */ protected function getRedisModuleVersion(string $module): string { if (isset($this->info)) { $info = $this->info; } else { $client = $this->createClient(null, null, true); $info = array_change_key_case($client->info()); $this->info = $info; } return $info['modules'][$module]['ver'] ?? '0'; } /** * Returns version of given module for current Redis instance. * Runs if command belong to one of modules and marked with appropriate annotation * Runs on @connected tests. * * @param string $module * @return string */ protected function getRequiredModuleVersion(string $module): ?string { if (!isset($this->modulesMapping[$module])) { throw new InvalidArgumentException('No existing annotation for given module'); } $moduleAnnotation = $this->modulesMapping[$module]['annotation']; $annotations = TestUtil::parseTestMethodAnnotations( get_class($this), $this->getName(false) ); if (isset($annotations['method'][$moduleAnnotation], $annotations['method']['group']) && !empty($annotations['method'][$moduleAnnotation]) && in_array('connected', $annotations['method']['group'], true) ) { return $annotations['method'][$moduleAnnotation][0]; } return null; } /** * Marks current test skipped when test suite is running on CI environments. * * @param string $message */ protected function markTestSkippedOnCIEnvironment(string $message = 'Test skipped on CI environment'): void { if (getenv('GITHUB_ACTIONS') || getenv('TRAVIS')) { $this->markTestSkipped($message); } } /** * Check annotations if it's matches to cluster test scenario. * * @return bool */ protected function isClusterTest(): bool { $annotations = TestUtil::parseTestMethodAnnotations( get_class($this), $this->getName(false) ); $annotationExists = isset($annotations['method']['requiresRedisVersion']); if (!$annotationExists) { foreach ($this->modulesMapping as $module => $configuration) { if (isset($annotations['method'][$configuration['annotation']])) { $annotationExists = true; } } } return $annotationExists && isset($annotations['method']['group']) && in_array('connected', $annotations['method']['group'], true) && in_array('cluster', $annotations['method']['group'], true); } /** * Check annotations if it's matches to SSL test scenario. * * @return bool */ protected function isSSLTest(): bool { $annotations = TestUtil::parseTestMethodAnnotations( get_class($this), $this->getName(false) ); $annotationExists = isset($annotations['method']['requiresRedisVersion']); if (!$annotationExists) { foreach ($this->modulesMapping as $module => $configuration) { if (isset($annotations['method'][$configuration['annotation']])) { $annotationExists = true; } } } return $annotationExists && isset($annotations['method']['group']) && in_array('connected', $annotations['method']['group'], true) && in_array('ssl', $annotations['method']['group'], true); } /** * Check annotations if it's matches to stack test scenario. * * @return bool */ protected function isStackTest(): bool { $annotations = TestUtil::parseTestMethodAnnotations( get_class($this), $this->getName(false) ); return isset($annotations['class']['group']) && in_array('realm-stack', $annotations['class']['group'], true); } /** * Check annotations if it's matches to unprotected test scenario. * * @return bool */ protected function isUnprotectedTest(): bool { $annotations = TestUtil::parseTestMethodAnnotations( get_class($this), $this->getName(false) ); return isset($annotations['method']['group']) && in_array('unprotected', $annotations['method']['group'], true); } /** * Check annotations if it's matches to sentinel test scenario. * * @return bool */ protected function isSentinelTest(): bool { $annotations = TestUtil::parseTestMethodAnnotations( get_class($this), $this->getName(false) ); return isset($annotations['method']['group']) && in_array('sentinel', $annotations['method']['group'], true); } /** * Parse comma-separated cluster endpoints and convert them into tcp strings. * * @return array */ protected function prepareClusterEndpoints(): array { $endpoints = explode( ',', constant($this->isSSLTest() ? 'SSL_REDIS_CLUSTER_ENDPOINTS' : 'REDIS_CLUSTER_ENDPOINTS') ); $scheme = $this->isSSLTest() ? 'tls' : 'tcp'; return array_map(static function (string $elem) use ($scheme) { return "{$scheme}://" . $elem; }, $endpoints); } /** * Parse comma-separated sentinel endpoints and convert them into tcp strings. * * @return array */ protected function prepareSentinelEndpoints(): array { $endpoints = explode(',', constant('REDIS_SENTINEL_ENDPOINTS')); return array_map(static function (string $elem) { return "tcp://{$elem}"; }, $endpoints); } }