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,
],
];