diff --git a/.gitattributes b/.gitattributes index 6fe14c5..16d59c5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,9 @@ /.gitignore export-ignore /.scrutinizer.yml export-ignore /.travis.yml export-ignore +/docker-compose.yml export-ignore +/phpcs.xml.dist export-ignore /phpspec.yml.dist export-ignore /phpunit.xml.dist export-ignore +/start-cluster.sh export-ignore /tests export-ignore diff --git a/.gitignore b/.gitignore index e534340..e966229 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ build/ +cluster/ docs/ vendor/ composer.phar composer.lock +docker-compose.override.yml +phpcs.xml phpspec.yml phpunit.xml diff --git a/.travis.yml b/.travis.yml index 5204df8..bcea0ac 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,6 @@ language: php dist: trusty +sudo: false php: - 5.4 @@ -7,14 +8,15 @@ php: - 5.6 - 7.0 - 7.1 + - 7.2 - hhvm env: - - LARAVEL_VERSION=5.0.* + - LARAVEL_VERSION=5.0.* NO_BROADCAST=1 - LARAVEL_VERSION=5.1.* - LARAVEL_VERSION=5.2.* - LARAVEL_VERSION=5.3.* - - LUMEN_VERSION=5.0.* + - LUMEN_VERSION=5.0.* NO_BROADCAST=1 - LUMEN_VERSION=5.1.* - LUMEN_VERSION=5.2.* - LUMEN_VERSION=5.3.* @@ -38,16 +40,22 @@ matrix: - php: 5.5 env: LUMEN_VERSION=5.3.* - php: 7.1 - env: LARAVEL_VERSION=5.0.* + env: LARAVEL_VERSION=5.0.* NO_BROADCAST=1 - php: 7.1 - env: LUMEN_VERSION=5.0.* + env: LUMEN_VERSION=5.0.* NO_BROADCAST=1 + - php: 7.2 + env: LARAVEL_VERSION=5.0.* NO_BROADCAST=1 + - php: 7.2 + env: LUMEN_VERSION=5.0.* NO_BROADCAST=1 -before_script: +before_install: - if [ -n "$LARAVEL_VERSION" ]; then composer remove --dev --no-update "laravel/lumen-framework"; fi - if [ -n "$LARAVEL_VERSION" ]; then composer require --no-update "laravel/framework:$LARAVEL_VERSION"; fi - if [ -n "$LUMEN_VERSION" ]; then composer remove --dev --no-update "laravel/framework"; fi - if [ -n "$LUMEN_VERSION" ]; then composer require --no-update "laravel/lumen-framework:$LUMEN_VERSION"; fi - - composer install --prefer-source --no-interaction -script: - - vendor/bin/phpunit +install: travis_retry composer install --prefer-dist --no-interaction --no-suggest + +before_script: SUPERVISE=no travis_retry ./start-cluster.sh || { cat ./cluster/*.log; false; } + +script: vendor/bin/phpunit --exclude-group ${LUMEN_VERSION:+laravel-only},${NO_BROADCAST:+broadcasting} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b5c2865 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: "3" + +services: + + cluster: + image: redis:alpine + entrypoint: ./start-cluster.sh + working_dir: /project + user: "${CONTAINER_USER_ID:-root:root}" + environment: + BIND_ADDRESS: "0.0.0.0" + SENTINEL_PORTS: "26379-26381" + REDIS_GROUP_1: "service1 6379-6381" + REDIS_GROUP_2: "service2 6382-6384" + LOGGING: "yes" + ports: + - "6379-6384:6379-6384" + - "26379-26381:26379-26381" + volumes: + - ./:/project + + tests: + image: phpunit/phpunit:latest + entrypoint: vendor/bin/phpunit --colors=always + network_mode: host + user: "${CONTAINER_USER_ID:-root:root}" + volumes: + - ./:/app diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9b7e44c..5e2047f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,8 +12,11 @@ stopOnFailure="false"> - - tests + + tests/Unit + + + tests/Integration @@ -23,6 +26,17 @@ + + + + + + + + + + diff --git a/start-cluster.sh b/start-cluster.sh new file mode 100755 index 0000000..68e6542 --- /dev/null +++ b/start-cluster.sh @@ -0,0 +1,502 @@ +#!/bin/sh +# +# Start a set of local Redis and Sentinel servers. +# +# Usage: [OPTION=VALUE]... ./start-cluster.sh [config|help] +# +# --- +# +# Package: Laravel Drivers for Redis Sentinel +# Author: Cy Rossignol +# Website: https://github.com/monospice/laravel-redis-sentinel-drivers +# License: The MIT License (MIT) +# +# Copyright (c) Monospice +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +WORKDIR="${WORKDIR:-./cluster}" +BIND_ADDRESS="${BIND_ADDRESS:-127.0.0.1}" +SENTINEL_PORTS="${SENTINEL_PORTS:-26379-26381}" +DOWN_AFTER="${DOWN_AFTER-3000}" +FAILOVER_TIMEOUT="${FAILOVER_TIMEOUT-10000}" +PARALLEL_SYNCS="${PARALLEL_SYNCS-1}" +TRUNCATE_LOGS="${TRUNCATE_LOGS:-yes}" +CLEANUP="${CLEANUP:-yes}" +SUPERVISE="${SUPERVISE:-yes}" +LOGGING="${LOGGING:-no}" + +if [ -z "$REDIS_GROUP_1" ] && [ -z "$REDIS_GROUP_2" ] \ + && [ -z "$REDIS_GROUP_3" ] && [ -z "$REDIS_GROUP_4" ] \ + && [ -z "$REDIS_GROUP_5" ] && [ -z "$REDIS_GROUP_6" ] \ + && [ -z "$REDIS_GROUP_7" ] && [ -z "$REDIS_GROUP_8" ] \ + && [ -z "$REDIS_GROUP_9" ] +then + REDIS_GROUP_1='service1 6379-6381' + REDIS_GROUP_2='service2 6382-6384' +fi + +if [ "$*" = 'config' ]; then + printf "%s='%s'\\n" \ + 'WORKDIR' "$WORKDIR" \ + 'BIND_ADDRESS' "$BIND_ADDRESS" \ + 'SENTINEL_PORTS' "$SENTINEL_PORTS" \ + 'REDIS_GROUP_1' "$REDIS_GROUP_1" \ + 'REDIS_GROUP_2' "$REDIS_GROUP_2" \ + 'REDIS_GROUP_3' "$REDIS_GROUP_3" \ + 'REDIS_GROUP_4' "$REDIS_GROUP_4" \ + 'REDIS_GROUP_5' "$REDIS_GROUP_5" \ + 'REDIS_GROUP_6' "$REDIS_GROUP_6" \ + 'REDIS_GROUP_7' "$REDIS_GROUP_7" \ + 'REDIS_GROUP_8' "$REDIS_GROUP_8" \ + 'REDIS_GROUP_9' "$REDIS_GROUP_9" \ + 'SENTINEL_CONF' "$SENTINEL_CONF" \ + 'REDIS_CONF' "$REDIS_CONF" \ + 'TRUNCATE_LOGS' "$TRUNCATE_LOGS" \ + 'CLEANUP' "$CLEANUP" \ + 'SUPERVISE' "$SUPERVISE" \ + 'LOGGING' "$LOGGING" + + exit 0 +fi + +if [ -n "$*" ]; then + printf ' +Start a set of local Redis and Sentinel servers. + + Usage: [OPTION=VALUE]... %s [config|help] + +This script accepts two arguments: + + config Display the current values of the configuration options. + help Show this help message. + +Options (from environment variables): + + WORKDIR The directory to place server logs and runtime files in. + BIND_ADDRESS Address of the interface to listen on (default: 127.0.0.1). + SENTINEL_PORTS Comma-separated port numbers or ranges for each Sentinel. + REDIS_GROUP_1..9 Group name and port numbers/ranges for a Redis master group + monitored by Sentinel. + DOWN_AFTER Milliseconds after which Sentinel considers a master down. + FAILOVER_TIMEOUT Milliseconds after which Sentinel retries a failover. + PARALLEL_SYNCS No. of replicas to resync simultaneously during failover. + SENTINEL_CONF Path to optional Sentinel server configuration file. + REDIS_CONF Path to optional Redis server configuration file. + TRUNCATE_LOGS Clear any existing logs on start-up (default: "yes"). + CLEANUP Remove server-created files except logs (default: "yes"). + SUPERVISE Stay in foreground (default: "yes"). + LOGGING Output server logs in supervised mode (default: "no"). + +With the default options, this tool creates the working directory in ./cluster +and starts three Sentinels and two groups of three Redis servers. The script +supports basic configuration by setting the environment variables shown above. +The following example shows how we can change the configuration by setting the +server working directory to /tmp/redis and starting only one Sentinel server: + + WORKDIR=/tmp/redis SENTINEL_PORTS=26379 %s + +The script starts a Sentinel instance for each port defined in SENTINEL_PORTS +and a Redis instance for every port defined in each assigned REDIS_GROUP_N +variable (where N is an integer in the range of 1 through 9). The generic +format expected for this variable is shown below: + + REDIS_GROUP_N="group-name port[,port,port-range,...]" + +For example: + + REDIS_GROUP_1="mymaster 6379-6381,6400" + REDIS_GROUP_2="cache 6382-6384" + +The script will initialize a Redis master for the first port in each group and +replicas for any remaining ports. + +The value of SUPERVISE determines whether the script should remain in the +foreground after starting the servers. This allows us to stop the cluster at +once by pressing Ctrl-C. The script runs in supervised mode by default. To +disable this behavior, set SUPERVISE to "no": + +To stop the servers in non-supervised mode, we can send the TERM signal to each +Redis process by finding the PIDs from the working directory: + + kill $(cat %s/*.pid) + +This script is suitable for the entrypoint command used to start a Docker +container test environment. Read the "Testing" section of the README for more +information.\n\n' "$0" "$0" "$WORKDIR" >&2 + + exit 1 +fi + +if ! command -v redis-cli > /dev/null; then + printf 'ERROR: Cannot find redis-cli. Verify that Redis is installed.' >&2 + + exit 1 +fi + +start_redis() { + printf 'Starting Redis server on port %d (%s)...\n' "$2" "$1" || return $? + + assert_not_listening "$2" + + if is_true "$TRUNCATE_LOGS"; then + printf '' > "$WORKDIR/redis-$2.log" + fi + + master_port="$3" + + set -- --port "$2" \ + --daemonize yes \ + --bind $BIND_ADDRESS \ + --dir "$WORKDIR" \ + --pidfile "redis-$2.pid" \ + --logfile "redis-$2.log" \ + --dbfilename "dump-$2.rdb" \ + --appendfilename "appendonly-$2.aof" + + if [ "$2" -ne "$master_port" ]; then + set -- "$@" --slaveof 127.0.0.1 "$master_port" + fi + + if [ -n "$REDIS_CONF" ]; then + cp "$REDIS_CONF" "$WORKDIR/redis-$2.conf" || return $? + set -- "$WORKDIR/redis-$2.conf" "$@" + fi + + redis-server "$@" +} + +start_sentinel() { + printf 'Starting Sentinel server on port %d...\n' "$1" || return $? + + assert_not_listening "$1" + + if is_true "$TRUNCATE_LOGS"; then + printf '' > "$WORKDIR/sentinel-$1.log" + fi + + write_sentinel_conf "$1" || return $? + + redis-server "$WORKDIR/sentinel-$1.conf" --sentinel \ + --daemonize yes \ + --bind $BIND_ADDRESS \ + --port "$1" \ + --dir "$WORKDIR" \ + --pidfile "sentinel-$1.pid" \ + --logfile "sentinel-$1.log" +} + +start_group() { + case "$1" in *[!A-Za-z0-9.-_]*) + printf 'ERROR: Invalid master group name: %s\n' "$1" >&2 && return 1 + esac + + group_ports="$(IFS=',' parse_ports "$2")" || return $? + master="${group_ports%% *}" + Redis_Ports="$Redis_Ports $group_ports" + + get_group_conf "$1" "$master" >> "$WORKDIR/sentinel-base.conf" || return $? + + for port in $group_ports; do + start_redis "$1" "$port" "$master" || return $? + assert_listening "$port" + done + + verify_replication "$group_ports" & Verify_Pids="$Verify_Pids $!" + verify_synchronization "$group_ports" & Verify_Pids="$Verify_Pids $!" +} + +get_group_conf() { + printf 'sentinel monitor %s 127.0.0.1 %d %d\n' "$1" "$2" "$Sentinel_Count" + + if [ -n "$DOWN_AFTER" ]; then + printf 'sentinel down-after-milliseconds %s %d\n' "$1" "$DOWN_AFTER" + fi + + if [ -n "$FAILOVER_TIMEOUT" ]; then + printf 'sentinel failover-timeout %s %d\n' "$1" "$FAILOVER_TIMEOUT" + fi + + if [ -n "$PARALLEL_SYNCS" ]; then + printf 'sentinel parallel-syncs %s %d\n' "$1" "$PARALLEL_SYNCS" + fi +} + +write_sentinel_conf() { + cp "$WORKDIR/sentinel-base.conf" "$WORKDIR/sentinel-$1.conf" || return $? + + if [ -z "$SENTINEL_CONF" ]; then + return 0 + fi + + while IFS='' read -r line || [ -n "$line" ]; do + case "$line" in *'sentinel monitor '*) + continue ;; + esac + + printf '%s\n' "$line" >> "$WORKDIR/sentinel-$1.conf" || return $? + done < "$SENTINEL_CONF" +} + +assert_listening() { + for timeout in 1 2 3 4 5 9; do + redis-cli -p "$1" PING > /dev/null 2>&1 && return 0 + sleep "0.$timeout" 2> /dev/null || sleep 1 + done + + redis-cli -p "$1" PING > /dev/null || terminate $? +} + +assert_not_listening() { + (reply="$(redis-cli -p "$1" PING 2>&1)" \ + || [ "${reply%*Connection refused}" = "$reply" ]) & + + pid=$! + + for timeout in 1 2 3 4 5; do + kill -0 "$pid" 2> /dev/null || break + sleep "0.$timeout" 2> /dev/null || sleep 1 + done + + if kill "$pid" 2> /dev/null || wait "$pid"; then + printf 'ERROR: Port %d already in use.\n' "$1" >&2 + terminate 1 + fi +} + +parse_ports() { + for ports in $1; do + end_port="${ports#*-}" + port="${ports%-*}" + + if ! [ "$end_port" -ge "$port" ] 2> /dev/null \ + || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then + printf 'ERROR: Invalid port or range: %s\n' "$ports" >&2 + + return 1 + fi + + until [ "$port" -gt "$end_port" ]; do + printf '%d ' "$port" + port="$(( port + 1 ))" + done + done +} + +count_items() { + set -- $1 + printf '%d' $# +} + +is_true() { + case "$1" in 1|[Yy]|yes|true) + return 0 ;; + esac + + return 1 +} + +wait_for_servers() { + while [ $# -gt 0 ]; do + if kill -0 "$@" 2> /dev/null && sleep 2; then + continue + fi + + for pid in "$@"; do + shift + + if kill -0 "$pid" 2> /dev/null; then + set -- "$@" "$pid" + else + printf 'WARNING: Process %d stopped unexpectedly.\n' "$pid" >&2 + fi + done + + sleep 2 + done + + printf 'ERROR: No servers running.\n' >&2 +} + +verify_replication() { + master_port="${1%% *}" + replica_count="$(count_items "${1#* }")" + + for timeout in 0 1 1 2 3 3; do + sleep "$timeout" + + info="$(redis-cli -p "$master_port" INFO replication)" || return $? + + case "$info" in *connected_slaves:${replica_count}[$(printf '\r\n')]*) + return 0 ;; + esac + done + + printf '\nReplicas failed to connect after 10 seconds.\n' >&2 + + return 1 +} + +verify_synchronization() { + replica_ports="${1#* }" + replica_count="$(count_items "$replica_ports")" + + for timeout in 0 1 1 2 3 3; do + sleep "$timeout" + + finished_sync_count=0 + + for port in $replica_ports; do + info="$(redis-cli -p "$port" INFO replication)" || return $? + + case "$info" in *master_sync_in_progress:0*) + finished_sync_count="$(( finished_sync_count + 1 ))" + esac + done; + + if [ "$finished_sync_count" -eq "$replica_count" ]; then + return 0 + fi + done + + printf '\nReplicas did not finish synchronization after 10 seconds.\n' >&2 + + return 1 +} + +verify_quorum() { + printf 'Waiting for Sentinel quorum consensus...' + + for timeout in 1 1 2 3 3; do + sleep "$timeout" + ok_groups='' + + for group in $Group_Names; do + reply="$(redis-cli -p "${SENTINEL_PORTS%% *}" \ + SENTINEL ckquorum "$group")" || return $? + + if [ "${reply%% *}" != 'OK' ]; then + break + fi + + ok_groups="$ok_groups $group" + done + + if [ "$ok_groups" = "$Group_Names" ]; then + printf 'done.\n' && return 0 + fi + done + + printf '\nERROR: Could not achieve quorum after 10 seconds.\n' >&2 + + return 1 +} + +start_logger() { + if ! command -v tail > /dev/null; then + printf 'WARNING: Cannot find the tail program. Logging disabled.\n' >&2 + + return 1 + fi + + Log_Pids='' + printf 'Watching server logs in %s...\n' "$WORKDIR" + + for port in $Redis_Ports $SENTINEL_PORTS; do + tail -n 50 -f "$WORKDIR/"*"-$port.log" | while IFS='' read -r line; do + printf '%5s: %s\n' "$port" "$line" + done & + + Log_Pids="$Log_Pids $!" + done +} + +terminate() { + trap - INT TERM + unset IFS + + if [ -n "$Log_Pids$Verify_Pids" ]; then + kill $Log_Pids $Verify_Pids 2> /dev/null + fi + + sleep 1 + + if server_pids="$(cat "$WORKDIR/"*.pid 2> /dev/null)"; then + printf 'Stopping %s...\n' "$WORKDIR/"*.pid 2> /dev/null + + kill $server_pids 2> /dev/null + wait_for_servers $server_pids 2> /dev/null + fi + + if is_true "$CLEANUP"; then + rm -f "$WORKDIR/"*.pid "$WORKDIR/"*.conf "$WORKDIR/"*.rdb + fi + + exit "$1" +} + +SENTINEL_PORTS="$(IFS=',' parse_ports "$SENTINEL_PORTS")" || exit $? +Sentinel_Count="$(count_items "$SENTINEL_PORTS")" +Redis_Ports='' +Group_Names='' +Verify_Pids='' + +mkdir -p "$WORKDIR" || exit $? +printf '' > "$WORKDIR/sentinel-base.conf" || exit $? + +trap 'terminate 130' INT +trap 'terminate 143' TERM + +for Group in \ + "$REDIS_GROUP_1" "$REDIS_GROUP_2" "$REDIS_GROUP_3" "$REDIS_GROUP_4" \ + "$REDIS_GROUP_5" "$REDIS_GROUP_6" "$REDIS_GROUP_7" "$REDIS_GROUP_8" \ + "$REDIS_GROUP_9" +do + if [ -n "$Group" ]; then + start_group "${Group%% *}" "${Group#* }" || terminate $? + Group_Names="$Group_Names ${Group%% *}" + fi +done + +unset IFS + +for Port in $SENTINEL_PORTS; do + start_sentinel "$Port" || terminate $? + assert_listening "$Port" +done + +printf 'Waiting for replicas to synchronize...' +wait $Verify_Pids && printf 'done.\n' || terminate $? +unset Verify_Pids + +verify_quorum || terminate $? + +if is_true "$SUPERVISE"; then + printf 'Press Ctrl-C to stop...\n' + trap 'printf "Shutting down...\n"; terminate 0' INT TERM + + server_pids="$(cat "$WORKDIR/"*.pid)" || exit $? + + if is_true "$LOGGING"; then + start_logger + fi + + wait_for_servers $server_pids +fi diff --git a/tests/Integration/Drivers/BroadcastingTest.php b/tests/Integration/Drivers/BroadcastingTest.php new file mode 100644 index 0000000..99fe05b --- /dev/null +++ b/tests/Integration/Drivers/BroadcastingTest.php @@ -0,0 +1,91 @@ +config->set(require(__DIR__ . '/../../stubs/config.php')); + $app->config->set('database.redis-sentinel', $this->config); + $app->register(new RedisSentinelServiceProvider($app)); + + if (! ApplicationFactory::isLumen()) { + $app->boot(); + } + + $broadcast = 'Illuminate\Contracts\Broadcasting\Factory'; + + $this->subject = $app->make($broadcast)->connection('redis-sentinel'); + } + + /** + * @group broadcasting + */ + public function testIsARedisBroadcaster() + { + $class = 'Illuminate\Broadcasting\Broadcasters\RedisBroadcaster'; + + $this->assertInstanceOf($class, $this->subject); + } + + /** + * @group broadcasting + */ + public function testBroadcastsAnEvent() + { + $message1 = $this->makeEventString('test-event-1'); + $message2 = $this->makeEventString('test-event-2'); + + $expected = [ + 'test-channel-1' => [ $message1, $message2 ], + 'test-channel-2' => [ $message1, $message2 ], + ]; + + $channels = array_keys($expected); + + $this->assertPublishes($expected, function () use ($channels) { + $this->subject->broadcast($channels, 'test-event-1'); + $this->subject->broadcast($channels, 'test-event-2'); + }); + } + + /** + * Create the string representation of a message for event broadcast. + * + * @param string $id A name that uniquely identifies the event. + * @param array $data Any event-specific data. + * @param string $socket The name of the socket to publish for. + * + * @return string The event message as JSON. + */ + protected function makeEventString($id, array $data = [ ], $socket = null) + { + $eventData = [ 'event' => $id, 'data' => $data ]; + + if (ApplicationFactory::getApplicationVersion() > 5.2) { + $eventData['socket'] = $socket; + } + + return json_encode($eventData); + } +} diff --git a/tests/Integration/Drivers/CacheTest.php b/tests/Integration/Drivers/CacheTest.php new file mode 100644 index 0000000..6605329 --- /dev/null +++ b/tests/Integration/Drivers/CacheTest.php @@ -0,0 +1,106 @@ +config->set(require(__DIR__ . '/../../stubs/config.php')); + $app->config->set('database.redis-sentinel', $this->config); + $app->register(new RedisSentinelServiceProvider($app)); + + if (! ApplicationFactory::isLumen()) { + $app->boot(); + } + + $this->cachePrefix = 'test'; + $app->config->set('cache.prefix', $this->cachePrefix); + + $this->subject = $app->cache->store('redis-sentinel'); + } + + public function testIsBackedByARedisCacheStore() + { + $class = 'Illuminate\Cache\RedisStore'; + + $this->assertInstanceOf($class, $this->subject->getStore()); + } + + public function testFetchesCachedData() + { + $cacheKey = $this->prefix('test-key'); + $expected = 'test value'; + + $this->testClient->set($cacheKey, serialize($expected)); + + $this->assertEquals($expected, $this->subject->get('test-key')); + } + + public function testStoresDataInCache() + { + $cacheKey = $this->prefix('test-key'); + $expected = 'test value'; + + $this->subject->forever('test-key', $expected); + + $this->assertRedisKeyEquals($cacheKey, serialize($expected)); + } + + public function testStoresPerishableDataInCache() + { + if (ApplicationFactory::getApplicationVersion() < 5.2) { + $this->markTestSkipped( + 'This test takes 60 seconds to pass in Laravel <= 5.1.' + ); + } + + $cacheKey = $this->prefix('test-key'); + $expected = 'test value'; + $oneSecondInMinutes = 1 / 60; + + $this->subject->put('test-key', $expected, $oneSecondInMinutes); + + $this->assertRedisKeyEquals($cacheKey, serialize($expected)); + usleep(1.2 * 1000000); + $this->assertRedisKeyEquals($cacheKey, null); + } + + /** + * Prepend the provided key with the current cache prefix. + * + * @param string $key The value to prefix + * + * @return string The cache key as stored in Redis. + */ + protected function prefix($key) + { + return $this->cachePrefix . ':' . $key; + } +} diff --git a/tests/Integration/Drivers/QueueTest.php b/tests/Integration/Drivers/QueueTest.php new file mode 100644 index 0000000..69cf31b --- /dev/null +++ b/tests/Integration/Drivers/QueueTest.php @@ -0,0 +1,91 @@ +config->set(require(__DIR__ . '/../../stubs/config.php')); + $app->config->set('database.redis-sentinel', $this->config); + $app->config->set('horizon.driver', 'default'); + $app->register(new RedisSentinelServiceProvider($app)); + + if (! ApplicationFactory::isLumen()) { + $app->boot(); + } + + $this->subject = $app->queue->connection('redis-sentinel'); + } + + public function testIsARedisQueue() + { + $this->assertInstanceOf('Illuminate\Queue\RedisQueue', $this->subject); + } + + public function testPushesJobOntoQueue() + { + $this->assertRedisListCount(self::QUEUE, 0); + + $this->subject->push('TestJob'); + + $this->assertRedisListCount(self::QUEUE, 1); + } + + public function testPopsJobOffQueue() + { + $this->subject->push('TestJob'); + $this->assertRedisListCount(self::QUEUE, 1); + + $job = $this->subject->pop(); + + $this->assertRedisListCount(self::QUEUE, 0); + $this->assertInstanceOf('Illuminate\Queue\Jobs\RedisJob', $job); + } + + public function testMovesDelayedJobsToReady() + { + $this->subject->later(0, 'TestJob'); + + $this->assertRedisListCount(self::QUEUE, 0); + $this->assertRedisSortedSetCount(self::QUEUE_DELAYED, 1); + + $this->subject->migrateExpiredJobs(self::QUEUE_DELAYED, self::QUEUE); + + $this->assertRedisSortedSetCount(self::QUEUE_DELAYED, 0); + $this->assertRedisListCount(self::QUEUE, 1); + } +} diff --git a/tests/Integration/Drivers/SessionTest.php b/tests/Integration/Drivers/SessionTest.php new file mode 100644 index 0000000..e2a41b0 --- /dev/null +++ b/tests/Integration/Drivers/SessionTest.php @@ -0,0 +1,77 @@ +config->set(require(__DIR__ . '/../../stubs/config.php')); + $app->config->set('database.redis-sentinel', $this->config); + $app->register(new RedisSentinelServiceProvider($app)); + $app->boot(); + + $this->subject = $app->session->driver('redis-sentinel'); + } + + /** + * @group laravel-only + */ + public function testIsBackedByARedisCacheStore() + { + $handler = $this->subject->getHandler(); + $class = 'Illuminate\Session\CacheBasedSessionHandler'; + $this->assertInstanceOf($class, $handler); + + $cacheStore = $handler->getCache()->getStore(); + $class = 'Illuminate\Cache\RedisStore'; + $this->assertInstanceOf($class, $cacheStore); + } + + /** + * @group laravel-only + */ + public function testFetchesSessionData() + { + $this->testClient->set($this->subject->getId(), serialize( + serialize([ 'test-key' => 'test value', ]) + )); + + $this->subject->start(); + + $this->assertEquals('test value', $this->subject->get('test-key')); + } + + /** + * @group laravel-only + */ + public function testSavesSessionData() + { + $this->subject->start(); + $this->subject->put('test-key', 'test value'); + $this->subject->save(); + + $this->assertRedisKeyEquals($this->subject->getId(), serialize( + serialize($this->subject->all()) + )); + } +} diff --git a/tests/Integration/PredisConnectionTest.php b/tests/Integration/PredisConnectionTest.php new file mode 100644 index 0000000..f7455b1 --- /dev/null +++ b/tests/Integration/PredisConnectionTest.php @@ -0,0 +1,352 @@ + [ 'test message 1', 'test message 2', ], + 'test-channel-2' => [ 'test message 1', 'test message 2', ], + ]; + + /** + * Run this setup before each test + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->subject = $this->makeClientSpy(); + } + + /** + * Run this cleanup after each test. + * + * @return void + */ + public function tearDown() + { + parent::tearDown(); + + Mockery::close(); + } + + public function testProvidesBaseClientForNonSentinelConnections() + { + $childClass = 'Monospice\LaravelRedisSentinel\PredisConnection'; + + $client = $this->subject->getClientFor('master'); + + $this->assertNotInstanceOf($childClass, $client); + } + + public function testAllowsSubscriptionsOnAggregateConnection() + { + // The Predis client itself does not currently support subscriptions on + // Sentinel connections and throws an exception that this class fixes. + $pubSub = $this->subject->pubSubLoop(); + + $this->assertInstanceOf('Predis\PubSub\Consumer', $pubSub); + } + + public function testSubscribesToPubSubChannels() + { + // Don't block the test with retries if it failed to read the expected + // number of messages from the server: + $this->subject->setRetryLimit(0); + + $received = [ ]; + + $test = function ($channels, $count) use (&$received) { + $this->subject->createSubscription( + $channels, + function ($message, $channel) use (&$received, &$count) { + $received[$channel][] = $message; + + if (--$count === 0) { + return false; + } + } + ); + }; + + $this->testClient->publishForTest($test, $this->expectedMessages); + + $this->assertEquals($this->expectedMessages, $received); + } + + public function testSubscribesToPubSubChannelsByPattern() + { + // Don't block the test with retries if it failed to read the expected + // number of messages from the server: + $this->subject->setRetryLimit(0); + + $received = [ ]; + + $test = function ($channels, $count) use (&$received) { + $this->subject->createSubscription( + 'test-channel-*', + function ($message, $channel) use (&$received, &$count) { + $received[$channel][] = $message; + + if (--$count === 0) { + return false; + } + }, + 'psubscribe' + ); + }; + + $this->testClient->publishForTest($test, $this->expectedMessages); + + $this->assertEquals($this->expectedMessages, $received); + } + + public function testSubscribesToPubSubChannelsUsingPredisApi() + { + // Don't block the test with retries if it failed to read the expected + // number of messages from the server: + $this->subject->setRetryLimit(0); + + $received = [ ]; + + $test = function ($channels, $count) use (&$received) { + $this->subject->pubSubLoop( + [ 'subscribe' => $channels ], + function ($loop, $message) use (&$received, &$count) { + if ($message->kind === 'message') { + $received[$message->channel][] = $message->payload; + + if (--$count === 0) { + return false; + } + } + } + ); + }; + + $this->testClient->publishForTest($test, $this->expectedMessages); + + $this->assertEquals($this->expectedMessages, $received); + } + + public function testRetriesSubscriptionWhenConnectionFails() + { + $this->switchToMinimumTimeout(); + + $expectedRetries = 2; + $this->subject = $this->makeClientSpy(); + $this->subject->setRetryLimit($expectedRetries); + $this->subject->setRetryWait(0); // retry immediately + + // With a read-write timeout, Predis throws a ConnectionException if + // nothing publishes to the channel for the duration specified by the + // timeout value. We'll use this with a low timeout to simulate a real + // connection failure so we don't need to block a server manually. + try { + $this->subject->createSubscription([ 'channel' ], function () { + return false; + }); + } catch (ConnectionException $exception) { + // With PHPUnit, we need to wrap the throwing block to perform + // assertions afterward. + } + + $this->subject->getConnection() // +1 for initial attempt: + ->shouldHaveReceived('querySentinel')->times($expectedRetries + 1); + } + + public function testSubscribesToSlaveByDefault() + { + $loop = $this->subject->pubSubLoop(); + $role = $loop->getClient()->executeRaw([ 'ROLE' ]); + + $this->assertEquals('slave', $role[0]); + } + + public function testSubscribeFallsBackToMaster() + { + $this->subject->getConnection() + ->shouldReceive('getSlaves')->andReturn([ ]); + + $loop = $this->subject->pubSubLoop(); + $role = $loop->getClient()->executeRaw([ 'ROLE' ]); + + $this->assertEquals('master', $role[0]); + } + + public function testAllowsTransactionsOnAggregateConnection() + { + // The Predis client itself does not currently support transactions on + // Sentinel connections and throws an exception that this class fixes. + $transaction = $this->subject->transaction(); + + $this->assertInstanceOf('Predis\Transaction\MultiExec', $transaction); + } + + public function testExecutesCommandsInTransaction() + { + $result = $this->subject->transaction(function ($trans) { + $trans->set('test-key', 'test value'); + $trans->get('test-key'); + }); + + $this->assertCount(2, $result); + $this->assertRedisResponseOk($result[0]); + $this->assertEquals('test value', $result[1]); + $this->assertRedisKeyEquals('test-key', 'test value'); + } + + public function testExecutesTransactionsOnMaster() + { + $expectedSubset = [ 'Replication' => [ 'role' => 'master' ] ]; + + $info = $this->subject->transaction(function ($transaction) { + // Predis doesn't let us call "ROLE" from a transaction. + $transaction->info(); + }); + + $this->assertArraySubset($expectedSubset, $info[0]); + } + + public function testAbortsTransactionOnException() + { + $exception = null; + + try { + $this->subject->transaction(function ($trans) { + $trans->set('test-key', 'test value'); + throw new DummyException(); + }); + } catch (DummyException $exception) { + // With PHPUnit, we need to wrap the throwing block to perform + // assertions afterward. + } + + $this->assertNotNull($exception); + $this->assertRedisKeyEquals('test-key', null); + } + + public function testRetriesTransactionWhenConnectionFails() + { + $expectedRetries = 2; + + $this->subject->setRetryLimit($expectedRetries); + $this->subject->setRetryWait(0); // retry immediately + + try { + $this->subject->transaction(function () { + $this->throwConnectionException(); + }); + } catch (ConnectionException $exception) { + // With PHPUnit, we need to wrap the throwing block to perform + // assertions afterward. + } + + $this->subject->getConnection()->shouldHaveReceived('querySentinel') + ->times($expectedRetries + 1); // increment for initial attempt + } + + public function testCanReconnectWhenConnectionFails() + { + $retries = ceil(2 / $this->switchToMinimumTimeout()) + 1; + $attempts = 0; + + $this->subject = $this->makeClientSpy(); + $this->subject->setRetryLimit((int) $retries); + $this->subject->setRetryWait(0); // retry immediately + $this->subject->connect(); + + // Do not block the master for more than the value of the Sentinel + // down-after-milliseconds configuration directive or Sentinel will + // initiate a failover: + $this->testClient->blockMasterFor(2, function () use (&$attempts) { + // Any retryable command re-implemented on the subject works, but + // we'll use transaction for the callback: + $this->subject->transaction(function ($trans) use (&$attempts) { + $attempts++; + $trans->set('test-key', 'test value'); + }); + }); + + $this->subject->getConnection() + ->shouldHaveReceived('querySentinel')->atLeast()->once(); + + $this->assertGreaterThan(1, $attempts, 'First try does not count.'); + $this->assertRedisKeyEquals('test-key', 'test value'); + } + + /** + * Initialize a Predis client spy using the test connection configuration + * that can verify connectivity failure handling. + * + * @return Client A client instance for the subject under test. + */ + protected function makeClientSpy() + { + // Yes, it's ugly--we're spying on the subject under test itself. We + // need to do this to verify internal calls to methods of the parent + // class of the subject (the Predis client). Unlike in the 2.x branch, + // this PredisConnection implementation extends the Predis client + // instead of consuming it as a dependency so we don't break backward + // compatibility in Laravel 5.3 and below. The use of a partial mock + // saves reams of code that we'd need to write to set up assertions + // using Redis directly. + // + // Use caution when mocking methods on these spies. In most cases, we + // just want to record that an internal method executed and pass the + // call through to the real class. + $clientSpy = Mockery::spy( + 'Monospice\LaravelRedisSentinel\PredisConnection', + [ + $this->config['default'], + $this->config['options'], + ] + )->makePartial(); + + $connectionSpy = Mockery::spy($clientSpy->getConnection()); + + $clientSpy->shouldReceive('getConnection') + ->andReturn($connectionSpy) + ->byDefault(); + + return $clientSpy; + } + + /** + * Simulate a Predis connection exception. + * + * @return void + * + * @throws ConnectionException Such as would occur when the client cannot + * connect to a Redis server. + */ + protected function throwConnectionException() + { + $nodeMock = Mockery::mock('Predis\Connection\NodeConnectionInterface'); + $nodeMock->shouldReceive('disconnect'); + + throw new ConnectionException($nodeMock); + } +} diff --git a/tests/Integration/RedisSentinelDatabaseTest.php b/tests/Integration/RedisSentinelDatabaseTest.php new file mode 100644 index 0000000..2c4a9c2 --- /dev/null +++ b/tests/Integration/RedisSentinelDatabaseTest.php @@ -0,0 +1,112 @@ + [ 'test message 1', 'test message 2', ], + 'test-channel-2' => [ 'test message 1', 'test message 2', ], + ]; + + /** + * Run this setup before each test + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->subject = new RedisSentinelDatabase($this->config); + } + + public function testExecutesRedisCommands() + { + // Just check that we can execute a Redis command all the way down and + // back up the stack. + $response = $this->subject->set('test-key', 'test value'); + + $this->assertRedisResponseOk($response); + $this->assertRedisKeyEquals('test-key', 'test value'); + $this->assertEquals('test value', $this->subject->get('test-key')); + } + + public function testExecutesRedisCommandsOnSpecificConnection() + { + $connection = $this->subject->connection('default'); + $response = $connection->set('test-key', 'test value'); + + $this->assertRedisResponseOk($response); + $this->assertRedisKeyEquals('test-key', 'test value'); + $this->assertEquals('test value', $connection->get('test-key')); + } + + public function testSubscribesToPubSubChannels() + { + // Don't block the test with retries if it failed to read the expected + // number of messages from the server: + $this->subject->setRetryLimit(0); + + $received = [ ]; + + $test = function ($channels, $count) use (&$received) { + $this->subject->subscribe( + $channels, + function ($message, $channel) use (&$received, &$count) { + $received[$channel][] = $message; + + if (--$count === 0) { + return false; + } + } + ); + }; + + $this->testClient->publishForTest($test, $this->expectedMessages); + + $this->assertEquals($this->expectedMessages, $received); + } + + public function testSubscribesToPubSubChannelsByPattern() + { + // Don't block the test with retries if it failed to read the expected + // number of messages from the server: + $this->subject->setRetryLimit(0); + + $received = [ ]; + + $test = function ($channels, $count) use (&$received) { + $this->subject->psubscribe( + 'test-channel-*', + function ($message, $channel) use (&$received, &$count) { + $received[$channel][] = $message; + + if (--$count === 0) { + return false; + } + } + ); + }; + + $this->testClient->publishForTest($test, $this->expectedMessages); + + $this->assertEquals($this->expectedMessages, $received); + } +} diff --git a/tests/Support/ApplicationFactory.php b/tests/Support/ApplicationFactory.php index 921d649..d5e4fc8 100644 --- a/tests/Support/ApplicationFactory.php +++ b/tests/Support/ApplicationFactory.php @@ -9,8 +9,19 @@ use Illuminate\Queue\QueueServiceProvider; use Illuminate\Redis\RedisServiceProvider; use Illuminate\Session\SessionServiceProvider; +use Illuminate\Support\Facades\Facade; use Illuminate\Support\Str; +/** + * Bootstraps Laravel and Lumen application instances for the version of the + * framework installed for testing. + * + * @category Package + * @package Monospice\LaravelRedisSentinel + * @author Cy Rossignol + * @license See LICENSE file + * @link https://github.com/monospice/laravel-redis-sentinel-drivers + */ class ApplicationFactory { /** @@ -69,6 +80,7 @@ public static function makeLaravelApplication($configure = true) $app->config = new ConfigRepository(); + Facade::setFacadeApplication($app); static::bootstrapEncryption($app); return $app; diff --git a/tests/Support/DummyException.php b/tests/Support/DummyException.php new file mode 100644 index 0000000..792a18c --- /dev/null +++ b/tests/Support/DummyException.php @@ -0,0 +1,18 @@ + + * @license See LICENSE file + * @link https://github.com/monospice/laravel-redis-sentinel-drivers + */ +class DummyException extends Exception +{ +} diff --git a/tests/Support/IntegrationTestCase.php b/tests/Support/IntegrationTestCase.php new file mode 100644 index 0000000..e1a5dbd --- /dev/null +++ b/tests/Support/IntegrationTestCase.php @@ -0,0 +1,278 @@ + + * @license See LICENSE file + * @link https://github.com/monospice/laravel-redis-sentinel-drivers + */ +abstract class IntegrationTestCase extends TestCase +{ + /** + * The "redis-sentinel" connection configuration to use for integration + * testing that wraps the settings declared in phpunit.xml. + * + * @var array + */ + protected $config; + + /** + * A Predis Client wrapper that connects to the same Sentinel servers as + * the classes under test for behavior verification and test clean-up. + * + * @var TestClient + */ + protected $testClient; + + /** + * Stores the message to display when skipping an integration test because + * of a problem with the test environment. + * + * @var string|bool + */ + private $skipTestReason; + + /** + * Run this setup before each test + * + * @return void + */ + public function setUp() + { + $this->skipIntegrationTestUnlessConfigured(); + $this->configureTest(); + + $this->testClient->ping(); + + $this->testClient->publish('>>>> STARTING TEST', $this->getFullName()); + } + + /** + * Run this cleanup after each test. + * + * @return void + */ + public function tearDown() + { + $this->testClient->publish('>>>> TEST FINISHED', $this->getFullName()); + + $this->testClient->flushdb(); + } + + /** + * Assert that Redis responded that a command executed successfully. + * + * @param mixed $response The response returned by the client from Redis. + * + * @return void + */ + public function assertRedisResponseOk($response) + { + $message = 'For Redis response:'; + $this->assertInstanceOf('Predis\Response\Status', $response, $message); + + $payload = $response->getPayload(); + $this->assertEquals('OK', $payload, $message); + } + + /** + * Assert that Redis contains the specified key. + * + * @param string $key The name of the key to check for. + * + * @return void + */ + public function assertRedisKeyExists($key) + { + $message = "When asserting Redis key exists: $key"; + + $this->assertTrue($this->testClient->exists($key) === 1, $message); + } + + /** + * Assert that the provided value equals the value at the specified key + * in Redis. + * + * @param string $key The key of the value in Redis to compare. + * @param mixed $expected The value that should exist for the key. + * + * @return void + */ + public function assertRedisKeyEquals($key, $expected) + { + $actual = $this->testClient->get($key); + $message = "For Redis key: $key"; + + $this->assertEquals($expected, $actual, $message); + } + + /** + * Assert that the number of items in the Redis list at the specified key + * equals the provided count. + * + * @param string $key The key of the list in Redis to compare + * @param int $expected The number of items that the list should contain. + * + * @return void + */ + public function assertRedisListCount($key, $expected) + { + $actual = $this->testClient->llen($key); + $message = "For Redis list at key: $key"; + + $this->assertEquals($expected, $actual, $message); + } + + /** + * Assert that the number of items in the Redis sorted set at the specified + * key equals the provided count. + * + * @param string $key The key of the sorted set in Redis to compare + * @param int $expected The number of items that the set should contain. + * + * @return void + */ + public function assertRedisSortedSetCount($key, $expected) + { + $actual = $this->testClient->zcard($key); + $message = "For Redis sorted set: $key"; + + $this->assertEquals($expected, $actual, $message); + } + + /** + * Assert that the specified message(s) are published to the corresponding + * Redis channel(s) when executing the provided callback. + * + * @param array $expectedMessages Two-dimensional array keyed by channel + * names. Each contains an array of message strings expected on the channel. + * @param Closure $callback Publishes the expected messages. + * + * @return void + */ + public function assertPublishes(array $expectedMessages, callable $callback) + { + $reader = new PubSubReader($this->testClient->getClientFor('master')); + + $this->assertEquals($expectedMessages, $reader->capture( + array_keys($expectedMessages), // channels + count(call_user_func_array('array_merge', $expectedMessages)), + $callback + )); + } + + /** + * Use the configured minimum timeout value for tests cases that verify + * failure handling behavior. + * + * @return float The minimum timeout value itself. + */ + public function switchToMinimumTimeout() + { + return $this->config['options']['parameters']['read_write_timeout'] + = TEST_MIN_CONNECTION_TIMEOUT; + } + + /** + * Read test environment configuration values provided by phpunit.xml and + * initalize a supporting test client. + * + * @return void + */ + private function configureTest() + { + $sentinels = explode(',', TEST_REDIS_SENTINEL_HOST); + + $options = [ + 'service' => TEST_REDIS_SENTINEL_SERVICE, + 'replication' => 'sentinel', + 'parameters' => [ + 'database' => TEST_REDIS_DATABASE, + 'timeout' => TEST_MAX_CONNECTION_TIMEOUT, + 'read_write_timeout' => TEST_MAX_CONNECTION_TIMEOUT, + ], + ]; + + $this->config = [ + 'default' => $sentinels, + 'options' => $options, + ]; + + $this->testClient = new TestClient($sentinels, $options); + } + + /** + * Mark an integration test as "skipped" if the phpunit configuration does + * not provide a valid set of connection settings. + * + * @return void + */ + private function skipIntegrationTestUnlessConfigured() + { + if ($this->skipTestReason === false) { + return; + } + + if ($this->skipTestReason !== null) { + $this->markTestSkipped($this->skipTestReason); + } + + if (! defined('TEST_REDIS_SENTINEL_HOST')) { + $this->skipBecause('No Sentinel hosts configured.'); + } + + if (! defined('TEST_REDIS_SENTINEL_SERVICE')) { + $this->skipBecause('No Sentinel service configured.'); + } + + if (! defined('TEST_REDIS_DATABASE')) { + $this->skipBecause('No Redis database number configured.'); + } + + if (! defined('TEST_MAX_CONNECTION_TIMEOUT')) { + $this->skipBecause('No maximum connection timeout configured.'); + } + + if (! defined('TEST_MIN_CONNECTION_TIMEOUT')) { + $this->skipBecause('No minimum connection timeout configured.'); + } + + $this->skipTestReason = false; + } + + /** + * Mark an integration test as "skipped" for the provided reason. + * + * @param string $reason Describes why we're skipping the test. + * + * @return void + */ + private function skipBecause($reason) + { + $this->skipTestReason = $reason; + + $this->markTestSkipped($reason); + } + + /** + * Get the name of the current test to publish in Redis. + * + * @return string The integration test namespace, class name, and test + * method name. + */ + private function getFullName() + { + return '...' . substr(get_class($this), 48) . '::' . $this->getName(); + } +} diff --git a/tests/Support/PubSubReader.php b/tests/Support/PubSubReader.php new file mode 100644 index 0000000..6cd7a92 --- /dev/null +++ b/tests/Support/PubSubReader.php @@ -0,0 +1,78 @@ + + * @license See LICENSE file + * @link http://github.com/monospice/laravel-redis-sentinel-drivers + */ +class PubSubReader +{ + /** + * The Predis client instance used to subscribe to the channel(s). Configure + * with a low read timeout to avoid blocking the test execution when a test + * fails to publish the expected number of messages. + * + * @var ClientInterface + */ + protected $client; + + /** + * Create a new reader using the provided client. + * + * @param ClientInterface $client The client instance used to subscribe + * to the channel(s). Configure it with a low read timeout. + */ + public function __construct(ClientInterface $client) + { + $this->client = $client; + } + + /** + * Capture any messages received on the provided channel(s). + * + * @param array $channels The channels to subscribe to. + * @param int $messageCount The number of expected messages to wait for. + * @param Closure $callback Publishes the expected messages. + * + * @return array Multi-dimensional array of the messages received keyed by + * the channels they were received on. + */ + public function capture(array $channels, $messageCount, Closure $callback) + { + $subscribedChannels = [ ]; + $messages = [ ]; + + $loop = $this->client->pubSubLoop([ 'psubscribe' => $channels ]); + + foreach ($loop as $message) { + if ($message->kind === 'psubscribe') { + $subscribedChannels[$message->channel] = true; + + if (count($channels) === count($subscribedChannels)) { + $callback(); + } + + continue; + } + + $messages[$message->channel][] = $message->payload; + + if (--$messageCount === 0) { + break; + } + } + + unset($loop); + + return $messages; + } +} diff --git a/tests/Support/TestClient.php b/tests/Support/TestClient.php new file mode 100644 index 0000000..9adf74e --- /dev/null +++ b/tests/Support/TestClient.php @@ -0,0 +1,194 @@ + + * @license See LICENSE file + * @link https://github.com/monospice/laravel-redis-sentinel-drivers + */ +class TestClient +{ + /** + * Indicates to the parent process that a background PHP process is ready + * to execute its Redis commands. + * + * @var string + */ + const BACKGROUND_PROCESS_READY_BEACON = '::READY::'; + + /** + * An instance of the Predis Client that connects to the same Sentinel + * servers as the classes under test for behavior verification and test + * clean-up. + * + * @var Client + */ + protected $client; + + /** + * Initalize a supporting test client used to validate Redis operations and + * control server availability. + * + * @param array $sentinels The Sentinel hosts to test against. + * @param array $options Testing-specific connection options. + * + * @return void + */ + public function __construct(array $sentinels, array $options) + { + $this->client = new Client($sentinels, array_merge($options, [ + 'parameters' => array_merge($options['parameters'], [ + 'persistent' => true, + ]), + ])); + + $connection = $this->client->getConnection(); + $connection->setRetryWait($options['parameters']['timeout']); + $connection->setRetryLimit(3); + } + + /** + * Publish the supplied messages to the specified channels after executing + * the provided callback. + * + * @param Closure $callback Executes subscribe() or psubscribe() commands. + * Receives an array of channel names in first argument and the number of + * messages to wait for. + * @param array $messages Two-dimensional array keyed by channel names. + * Each contains an array of message strings to publish on the channel. + * + * @return void + */ + public function publishForTest(Closure $callback, array $messages) + { + $callback = function () use ($messages, $callback) { + $channels = array_keys($messages); + $count = count(call_user_func_array('array_merge', $messages)); + + $callback($channels, $count); + }; + + $stringMessages = var_export($messages, true); + + $process = $this->makeBackgroundCommandProcessForMaster(" + usleep(100000); + + foreach ($stringMessages as \$channel => \$messages) { + foreach (\$messages as \$message) { + \$client->publish(\$channel, \$message); + } + } + "); + + $process->mustRun(function ($type, $buffer) use ($callback) { + if ($buffer === self::BACKGROUND_PROCESS_READY_BEACON) { + $callback(); + } + }); + } + + /** + * Signal the current Redis master to sleep for the specified number of + * seconds. + * + * WARNING: Performing this operation with a duration greater than the + * value of the Sentinel down-after-milliseconds directive for the current + * master group will cause Sentinel to initiate a failover. + * + * @param int $seconds The number of seconds the master will sleep for. + * @param Closure $callback Executes any operations to perform after putting + * the master to sleep. If NULL, the call blocks until the master wakes up. + * + * @return void + */ + public function blockMasterFor($seconds, Closure $callback = null) + { + if ($callback === null) { + $this->getMaster()->executeRaw([ 'DEBUG', 'SLEEP', $seconds ]); + + return; + } + + $process = $this->makeBackgroundCommandProcessForMaster(" + \$client->executeRaw([ 'DEBUG', 'SLEEP', $seconds ]); + "); + + $process->mustRun(function ($type, $buffer) use ($callback) { + if ($buffer === self::BACKGROUND_PROCESS_READY_BEACON) { + usleep(1000); + $callback(); + } + }); + } + + /** + * Get a client instance for the current Redis master. + * + * @return \Predis\ClientInterface Connects to the current master without + * querying Sentinel. + */ + public function getMaster() + { + return $this->client->getClientFor('master'); + } + + /** + * Proxy dynamic method calls to the current Predis client instance. + * + * @param string $method The name of the invoked method. + * @param array $arguments Any arguments passed to the method. + * + * @return mixed The return value from the proxied method call. + */ + public function __call($method, array $arguments) + { + return DynamicMethod::from($method)->callOn($this->client, $arguments); + } + + /** + * Initialize an object that starts a background PHP process to execute + * the provided Redis command(s) without blocking the test. + * + * @param string $script The Redis commands to execute represented as a PHP + * script using the Predis client. + * + * @return PhpProcess Used to start the background process and collect any + * output. + */ + protected function makeBackgroundCommandProcessForMaster($script) + { + $parameters = $this->getMaster()->getConnection()->getParameters(); + $scriptPath = __DIR__; + + return new PhpProcess(" + '{$parameters->scheme}', + 'host' => '{$parameters->host}', + 'port' => {$parameters->port}, + 'database' => {$parameters->database}, + ]); + + \$client->ping(); + + echo '" . self::BACKGROUND_PROCESS_READY_BEACON . "'; + flush(); + + $script + ?> + "); + } +} diff --git a/tests/Configuration/LoaderTest.php b/tests/Unit/Configuration/LoaderTest.php similarity index 99% rename from tests/Configuration/LoaderTest.php rename to tests/Unit/Configuration/LoaderTest.php index b315d67..d5776e0 100644 --- a/tests/Configuration/LoaderTest.php +++ b/tests/Unit/Configuration/LoaderTest.php @@ -1,6 +1,6 @@ config->set('database.redis.driver', 'redis-sentinel'); $this->assertTrue($this->loader->shouldOverrideLaravelRedisApi()); - // Previous versios of the package looked for the value 'sentinel': + // Previous versions of the package looked for the value 'sentinel': $this->config->set('database.redis.driver', 'sentinel'); $this->assertTrue($this->loader->shouldOverrideLaravelRedisApi()); diff --git a/tests/Unit/PredisConnectionTest.php b/tests/Unit/PredisConnectionTest.php new file mode 100644 index 0000000..94fbdaa --- /dev/null +++ b/tests/Unit/PredisConnectionTest.php @@ -0,0 +1,84 @@ + 0.99, + 'retry_limit' => 99, + 'retry_wait' => 9999, + 'update_sentinels' => true, + ]; + + /** + * The instance of the Predis client wrapper under test. + * + * @var PredisConnection + */ + protected $subject; + + /** + * Run this setup before each test. + * + * @return void + */ + public function setUp() + { + $config = require __DIR__ . '/../stubs/config.php'; + + $this->subject = new PredisConnection( + $config['database']['redis-sentinel']['default'], + array_merge($config['database']['redis-sentinel']['options'], [ + 'replication' => 'sentinel' + ]) + ); + } + + public function testIsInitializable() + { + $class = 'Monospice\LaravelRedisSentinel\PredisConnection'; + + $this->assertInstanceOf($class, $this->subject); + } + + public function testIsAPredisClient() + { + $interface = 'Predis\ClientInterface'; + + $this->assertInstanceOf($interface, $this->subject); + } + + public function testSetsSentinelOptionsFluentlyThroughApi() + { + $connection = $this->subject->getConnection(); + + foreach (static::$sentinelOptions as $option => $value) { + $method = DynamicMethod::parseFromUnderscore($option); + $property = $method->name(); + + $returnValue = $method->prepend('set') + ->callOn($this->subject, [ $value ]); + + $this->assertSame($this->subject, $returnValue); + + // These classes provide no public interface to detect these values + $this->assertAttributeEquals($value, $property, $connection); + } + + $this->assertAttributeEquals(99, 'retryLimit', $this->subject); + $this->assertAttributeEquals(9999, 'retryWait', $this->subject); + } +} diff --git a/tests/RedisSentinelDatabaseTest.php b/tests/Unit/RedisSentinelDatabaseTest.php similarity index 76% rename from tests/RedisSentinelDatabaseTest.php rename to tests/Unit/RedisSentinelDatabaseTest.php index e90e0ce..064f8df 100644 --- a/tests/RedisSentinelDatabaseTest.php +++ b/tests/Unit/RedisSentinelDatabaseTest.php @@ -1,6 +1,6 @@ database = new RedisSentinelDatabase($config); + $this->subject = new RedisSentinelDatabase($config); } public function testIsInitializable() { $class = 'Monospice\LaravelRedisSentinel\RedisSentinelDatabase'; - $this->assertInstanceOf($class, $this->database); + $this->assertInstanceOf($class, $this->subject); } public function testExtendsRedisDatabaseForSwapability() { $extends = 'Illuminate\Redis\Database'; - $this->assertInstanceOf($extends, $this->database); + $this->assertInstanceOf($extends, $this->subject); } public function testCreatesSentinelPredisClientsForEachConnection() { - $client1 = $this->database->connection('connection1'); - $client2 = $this->database->connection('connection2'); + $client1 = $this->subject->connection('default'); + $client2 = $this->subject->connection('connection2'); foreach ([ $client1, $client2 ] as $client) { $options = $client->getOptions(); @@ -63,8 +63,8 @@ public function testCreatesSentinelPredisClientsForEachConnection() public function testSetsSentinelConnectionOptionsFromConfig() { - $client1 = $this->database->connection('connection1'); - $client2 = $this->database->connection('connection2'); + $client1 = $this->subject->connection('default'); + $client2 = $this->subject->connection('connection2'); foreach ([ $client1, $client2 ] as $client) { $connection = $client1->getConnection(); @@ -80,8 +80,8 @@ public function testSetsSentinelConnectionOptionsFromConfig() public function testCreatesSingleClientsWithSharedConfig() { - $client1 = $this->database->connection('connection1'); - $client2 = $this->database->connection('connection2'); + $client1 = $this->subject->connection('default'); + $client2 = $this->subject->connection('connection2'); foreach ([ $client1, $client2 ] as $client) { $expected = [ 'password' => 'secret', 'database' => 0 ]; @@ -93,8 +93,8 @@ public function testCreatesSingleClientsWithSharedConfig() public function testCreatesSingleClientsWithIndividualConfig() { - $client1 = $this->database->connection('connection1'); - $client2 = $this->database->connection('connection2'); + $client1 = $this->subject->connection('default'); + $client2 = $this->subject->connection('connection2'); $this->assertEquals('mymaster', $client1->getOptions()->service); $this->assertEquals('another-master', $client2->getOptions()->service); diff --git a/tests/RedisSentinelFacadeTest.php b/tests/Unit/RedisSentinelFacadeTest.php similarity index 94% rename from tests/RedisSentinelFacadeTest.php rename to tests/Unit/RedisSentinelFacadeTest.php index e6280e2..0416327 100644 --- a/tests/RedisSentinelFacadeTest.php +++ b/tests/Unit/RedisSentinelFacadeTest.php @@ -1,6 +1,6 @@ app = ApplicationFactory::make(); - $this->app->config->set(require(__DIR__ . '/stubs/config.php')); + $this->app->config->set(require(__DIR__ . '/../stubs/config.php')); $this->provider = new RedisSentinelServiceProvider($this->app); } diff --git a/tests/stubs/config.php b/tests/stubs/config.php index 320dde0..8657c80 100644 --- a/tests/stubs/config.php +++ b/tests/stubs/config.php @@ -13,7 +13,7 @@ 'driver' => 'redis-sentinel', ], 'redis-sentinel' => [ - 'connection1' => [ + 'default' => [ [ 'host' => 'localhost', 'port' => 26379, @@ -49,7 +49,7 @@ 'connections' => [ 'redis-sentinel' => [ 'driver' => 'redis-sentinel', - 'connection' => 'connection1', + 'connection' => 'default', ], ], ], @@ -59,7 +59,7 @@ 'stores' => [ 'redis-sentinel' => [ 'driver' => 'redis-sentinel', - 'connection' => 'connection1', + 'connection' => 'default', ], ], ], @@ -69,7 +69,7 @@ 'connections' => [ 'redis-sentinel' => [ 'driver' => 'redis-sentinel', - 'connection' => 'connection1', + 'connection' => 'default', 'queue' => 'default', 'expire' => 60, ], @@ -79,7 +79,8 @@ // Represents a subset of config/session.php 'session' => [ 'driver' => 'redis-sentinel', - 'connection' => 'connection1', + 'connection' => 'default', + 'lifetime' => 120, ], ];