From d11d91d47c41c71453729e7ba1ded078e1cf81c3 Mon Sep 17 00:00:00 2001
From: Demian Katz <demian.katz@villanova.edu>
Date: Thu, 30 Jan 2020 14:34:06 -0500
Subject: [PATCH] Improve session handlers (VUFIND-1355). (#1506)

- Move configuration setting to constructor via factory (instead of using setConfig in ManagerFactory)
- Use camelCase for more parameters/variables
- Simplify/condense code where possible
- Add test coverage
---
 .../src/VuFind/Session/AbstractBase.php       |  62 +++----
 .../VuFind/Session/AbstractBaseFactory.php    |  71 ++++++++
 module/VuFind/src/VuFind/Session/Database.php |  30 ++--
 module/VuFind/src/VuFind/Session/File.php     |  92 +++++-----
 .../src/VuFind/Session/HandlerInterface.php   |  11 --
 .../src/VuFind/Session/ManagerFactory.php     |   7 +-
 module/VuFind/src/VuFind/Session/Memcache.php |  65 ++++---
 .../src/VuFind/Session/PluginManager.php      |  10 +-
 module/VuFind/src/VuFind/Session/Redis.php    |  67 +++-----
 .../src/VuFind/Session/RedisFactory.php       |  95 +++++++++++
 .../Unit/SessionHandlerTestCase.php           | 108 ++++++++++++
 .../src/VuFindTest/Session/DatabaseTest.php   | 158 +++++++++++++++++
 .../src/VuFindTest/Session/FileTest.php       | 144 ++++++++++++++++
 .../src/VuFindTest/Session/MemcacheTest.php   | 159 ++++++++++++++++++
 .../src/VuFindTest/Session/RedisTest.php      | 136 +++++++++++++++
 15 files changed, 1021 insertions(+), 194 deletions(-)
 create mode 100644 module/VuFind/src/VuFind/Session/AbstractBaseFactory.php
 create mode 100644 module/VuFind/src/VuFind/Session/RedisFactory.php
 create mode 100644 module/VuFind/src/VuFindTest/Unit/SessionHandlerTestCase.php
 create mode 100644 module/VuFind/tests/unit-tests/src/VuFindTest/Session/DatabaseTest.php
 create mode 100644 module/VuFind/tests/unit-tests/src/VuFindTest/Session/FileTest.php
 create mode 100644 module/VuFind/tests/unit-tests/src/VuFindTest/Session/MemcacheTest.php
 create mode 100644 module/VuFind/tests/unit-tests/src/VuFindTest/Session/RedisTest.php

diff --git a/module/VuFind/src/VuFind/Session/AbstractBase.php b/module/VuFind/src/VuFind/Session/AbstractBase.php
index 1e5c24847ec..dac4afc1bc8 100644
--- a/module/VuFind/src/VuFind/Session/AbstractBase.php
+++ b/module/VuFind/src/VuFind/Session/AbstractBase.php
@@ -54,13 +54,6 @@ abstract class AbstractBase implements HandlerInterface
      */
     protected $lifetime = 3600;
 
-    /**
-     * Session configuration settings
-     *
-     * @var Config
-     */
-    protected $config = null;
-
     /**
      * Whether writes are disabled, i.e. any changes to the session are not written
      * to the storage
@@ -70,39 +63,36 @@ abstract class AbstractBase implements HandlerInterface
     protected $writesDisabled = false;
 
     /**
-     * Enable session writing (default)
+     * Constructor
      *
-     * @return void
+     * @param Config $config Session configuration ([Session] section of
+     * config.ini)
      */
-    public function enableWrites()
+    public function __construct(Config $config = null)
     {
-        $this->writesDisabled = false;
+        if (isset($config->lifetime)) {
+            $this->lifetime = $config->lifetime;
+        }
     }
 
     /**
-     * Disable session writing, i.e. make it read-only
+     * Enable session writing (default)
      *
      * @return void
      */
-    public function disableWrites()
+    public function enableWrites()
     {
-        $this->writesDisabled = true;
+        $this->writesDisabled = false;
     }
 
     /**
-     * Set configuration.
-     *
-     * @param Config $config Session configuration ([Session] section of
-     * config.ini)
+     * Disable session writing, i.e. make it read-only
      *
      * @return void
      */
-    public function setConfig(Config $config)
+    public function disableWrites()
     {
-        if (isset($config->lifetime)) {
-            $this->lifetime = $config->lifetime;
-        }
-        $this->config = $config;
+        $this->writesDisabled = true;
     }
 
     /**
@@ -140,16 +130,16 @@ abstract class AbstractBase implements HandlerInterface
      *             mechanisms.  If you override this method, be sure to still call
      *             parent::destroy() in addition to any new behavior.
      *
-     * @param string $sess_id The session ID to destroy
+     * @param string $sessId The session ID to destroy
      *
      * @return bool
      */
-    public function destroy($sess_id)
+    public function destroy($sessId)
     {
         $searchTable = $this->getTable('Search');
-        $searchTable->destroySession($sess_id);
+        $searchTable->destroySession($sessId);
         $sessionTable = $this->getTable('ExternalSession');
-        $sessionTable->destroySession($sess_id);
+        $sessionTable->destroySession($sessId);
         return true;
     }
 
@@ -157,13 +147,13 @@ abstract class AbstractBase implements HandlerInterface
      * The garbage collector, this is executed when the session garbage collector
      * is executed and takes the max session lifetime as its only parameter.
      *
-     * @param int $sess_maxlifetime Maximum session lifetime.
+     * @param int $sessMaxLifetime Maximum session lifetime.
      *
      * @return bool
      *
      * @SuppressWarnings(PHPMD.UnusedFormalParameter)
      */
-    public function gc($sess_maxlifetime)
+    public function gc($sessMaxLifetime)
     {
         // how often does this get called (if at all)?
 
@@ -182,26 +172,26 @@ abstract class AbstractBase implements HandlerInterface
     /**
      * Write function that is called when session data is to be saved.
      *
-     * @param string $sess_id The current session ID
-     * @param string $data    The session data to write
+     * @param string $sessId The current session ID
+     * @param string $data   The session data to write
      *
      * @return bool
      */
-    public function write($sess_id, $data)
+    public function write($sessId, $data)
     {
         if ($this->writesDisabled) {
             return true;
         }
-        return $this->saveSession($sess_id, $data);
+        return $this->saveSession($sessId, $data);
     }
 
     /**
      * A function that is called internally when session data is to be saved.
      *
-     * @param string $sess_id The current session ID
-     * @param string $data    The session data to write
+     * @param string $sessId The current session ID
+     * @param string $data   The session data to write
      *
      * @return bool
      */
-    abstract protected function saveSession($sess_id, $data);
+    abstract protected function saveSession($sessId, $data);
 }
diff --git a/module/VuFind/src/VuFind/Session/AbstractBaseFactory.php b/module/VuFind/src/VuFind/Session/AbstractBaseFactory.php
new file mode 100644
index 00000000000..f01c67f9175
--- /dev/null
+++ b/module/VuFind/src/VuFind/Session/AbstractBaseFactory.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Generic factory for instantiating session handlers
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Session_Handlers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\Session;
+
+use Interop\Container\ContainerInterface;
+use Zend\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Generic factory for instantiating session handlers
+ *
+ * @category VuFind
+ * @package  Session_Handlers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ *
+ * @codeCoverageIgnore
+ */
+class AbstractBaseFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+
+        $config = $container->get(\VuFind\Config\PluginManager::class)
+            ->get('config');
+        return new $requestedName($config->Session ?? null);
+    }
+}
diff --git a/module/VuFind/src/VuFind/Session/Database.php b/module/VuFind/src/VuFind/Session/Database.php
index da9b7f3ea1f..8b36c02a189 100644
--- a/module/VuFind/src/VuFind/Session/Database.php
+++ b/module/VuFind/src/VuFind/Session/Database.php
@@ -44,18 +44,18 @@ class Database extends AbstractBase
      * Read function must return string value always to make save handler work as
      * expected. Return empty string if there is no data to read.
      *
-     * @param string $sess_id The session ID to read
+     * @param string $sessId The session ID to read
      *
      * @return string
      */
-    public function read($sess_id)
+    public function read($sessId)
     {
         // Try to read the session, but destroy it if it has expired:
         try {
             return $this->getTable('Session')
-                ->readSession($sess_id, $this->lifetime);
+                ->readSession($sessId, $this->lifetime);
         } catch (SessionExpiredException $e) {
-            $this->destroy($sess_id);
+            $this->destroy($sessId);
             return '';
         }
     }
@@ -64,17 +64,17 @@ class Database extends AbstractBase
      * The destroy handler, this is executed when a session is destroyed with
      * session_destroy() and takes the session id as its only parameter.
      *
-     * @param string $sess_id The session ID to destroy
+     * @param string $sessId The session ID to destroy
      *
      * @return bool
      */
-    public function destroy($sess_id)
+    public function destroy($sessId)
     {
         // Perform standard actions required by all session methods:
-        parent::destroy($sess_id);
+        parent::destroy($sessId);
 
         // Now do database-specific destruction:
-        $this->getTable('Session')->destroySession($sess_id);
+        $this->getTable('Session')->destroySession($sessId);
 
         return true;
     }
@@ -83,27 +83,27 @@ class Database extends AbstractBase
      * The garbage collector, this is executed when the session garbage collector
      * is executed and takes the max session lifetime as its only parameter.
      *
-     * @param int $sess_maxlifetime Maximum session lifetime.
+     * @param int $sessMaxLifetime Maximum session lifetime.
      *
      * @return bool
      */
-    public function gc($sess_maxlifetime)
+    public function gc($sessMaxLifetime)
     {
-        $this->getTable('Session')->garbageCollect($sess_maxlifetime);
+        $this->getTable('Session')->garbageCollect($sessMaxLifetime);
         return true;
     }
 
     /**
      * A function that is called internally when session data is to be saved.
      *
-     * @param string $sess_id The current session ID
-     * @param string $data    The session data to write
+     * @param string $sessId The current session ID
+     * @param string $data   The session data to write
      *
      * @return bool
      */
-    protected function saveSession($sess_id, $data)
+    protected function saveSession($sessId, $data)
     {
-        $this->getTable('Session')->writeSession($sess_id, $data);
+        $this->getTable('Session')->writeSession($sessId, $data);
         return true;
     }
 }
diff --git a/module/VuFind/src/VuFind/Session/File.php b/module/VuFind/src/VuFind/Session/File.php
index a4773f693a0..110e4533065 100644
--- a/module/VuFind/src/VuFind/Session/File.php
+++ b/module/VuFind/src/VuFind/Session/File.php
@@ -27,6 +27,8 @@
  */
 namespace VuFind\Session;
 
+use Zend\Config\Config;
+
 /**
  * File-based session handler
  *
@@ -39,84 +41,80 @@ namespace VuFind\Session;
 class File extends AbstractBase
 {
     /**
-     * Path to session file (boolean false until set)
+     * Path to session file
      *
-     * @var string|bool
+     * @var string
      */
-    protected $path = false;
+    protected $path;
 
     /**
-     * Get the file path for writing sessions.
+     * Constructor
      *
-     * @throws \Exception
-     * @return string
+     * @param Config $config Session configuration ([Session] section of
+     * config.ini)
      */
-    protected function getPath()
+    public function __construct(Config $config = null)
     {
-        if (!$this->path) {
-            // Set defaults if nothing set in config file.
-            if (isset($this->config->file_save_path)) {
-                $this->path = $this->config->file_save_path;
-            } else {
-                $tempdir = function_exists('sys_get_temp_dir')
-                    ? sys_get_temp_dir() : DIRECTORY_SEPARATOR . 'tmp';
-                $this->path = $tempdir . DIRECTORY_SEPARATOR . 'vufind_sessions';
-            }
+        parent::__construct($config);
 
-            // Die if the session directory does not exist and cannot be created.
-            if (!file_exists($this->path) || !is_dir($this->path)) {
-                if (!mkdir($this->path)) {
-                    throw new \Exception(
-                        "Cannot access session save path: " . $this->path
-                    );
-                }
-            }
+        // Set defaults if nothing set in config file.
+        if (isset($config->file_save_path)) {
+            $this->path = $config->file_save_path;
+        } else {
+            $tempdir = function_exists('sys_get_temp_dir')
+                ? sys_get_temp_dir() : DIRECTORY_SEPARATOR . 'tmp';
+            $this->path = $tempdir . DIRECTORY_SEPARATOR . 'vufind_sessions';
         }
 
-        return $this->path;
+        // Die if the session directory does not exist and cannot be created.
+        if ((!file_exists($this->path) || !is_dir($this->path))
+            && !mkdir($this->path)
+        ) {
+            throw new \Exception("Cannot access session save path: " . $this->path);
+        }
     }
 
     /**
      * Read function must return string value always to make save handler work as
      * expected. Return empty string if there is no data to read.
      *
-     * @param string $sess_id The session ID to read
+     * @param string $sessId The session ID to read
      *
      * @return string
      */
-    public function read($sess_id)
+    public function read($sessId)
     {
-        $sess_file = $this->getPath() . '/sess_' . $sess_id;
-        if (!file_exists($sess_file)) {
+        $sessFile = $this->path . '/sess_' . $sessId;
+        if (!file_exists($sessFile)) {
             return '';
         }
 
         // enforce lifetime of this session data
-        if (filemtime($sess_file) + $this->lifetime <= time()) {
-            $this->destroy($sess_id);
+        if (filemtime($sessFile) + $this->lifetime <= time()) {
+            $this->destroy($sessId);
             return '';
         }
 
-        return (string)file_get_contents($sess_file);
+        return (string)file_get_contents($sessFile);
     }
 
     /**
      * The destroy handler, this is executed when a session is destroyed with
      * session_destroy() and takes the session id as its only parameter.
      *
-     * @param string $sess_id The session ID to destroy
+     * @param string $sessId The session ID to destroy
      *
      * @return bool
      */
-    public function destroy($sess_id)
+    public function destroy($sessId)
     {
         // Perform standard actions required by all session methods:
-        parent::destroy($sess_id);
+        parent::destroy($sessId);
 
         // Perform file-specific cleanup:
-        $sess_file = $this->getPath() . '/sess_' . $sess_id;
-        if (file_exists($sess_file)) {
-            return unlink($sess_file);
+        $sessFile = $this->path . '/sess_' . $sessId;
+        if (file_exists($sessFile)) {
+            return unlink($sessFile);
         }
         return true;
     }
@@ -131,7 +129,7 @@ class File extends AbstractBase
      */
     public function gc($maxlifetime)
     {
-        foreach (glob($this->getPath() . "/sess_*") as $filename) {
+        foreach (glob($this->path . "/sess_*") as $filename) {
             if (filemtime($filename) + $maxlifetime < time()) {
                 unlink($filename);
             }
@@ -142,17 +140,17 @@ class File extends AbstractBase
     /**
      * A function that is called internally when session data is to be saved.
      *
-     * @param string $sess_id The current session ID
-     * @param string $data    The session data to write
+     * @param string $sessId The current session ID
+     * @param string $data   The session data to write
      *
      * @return bool
      */
-    protected function saveSession($sess_id, $data)
+    protected function saveSession($sessId, $data)
     {
-        $sess_file = $this->getPath() . '/sess_' . $sess_id;
-        if ($fp = fopen($sess_file, "w")) {
-            $return = fwrite($fp, $data);
-            fclose($fp);
+        $sessFile = $this->path . '/sess_' . $sessId;
+        if ($handle = fopen($sessFile, "w")) {
+            $return = fwrite($handle, $data);
+            fclose($handle);
             if ($return !== false) {
                 return true;
             }
@@ -161,7 +159,7 @@ class File extends AbstractBase
         // It is tempting to throw an exception here, but this code is called
         // outside of the context of exception handling, so all we can do is
         // echo a message.
-        echo 'Cannot write session to ' . $sess_file . "\n";
+        echo 'Cannot write session to ' . $sessFile . "\n";
         return false;
     }
 }
diff --git a/module/VuFind/src/VuFind/Session/HandlerInterface.php b/module/VuFind/src/VuFind/Session/HandlerInterface.php
index bd69dc3452a..5aad643f31a 100644
--- a/module/VuFind/src/VuFind/Session/HandlerInterface.php
+++ b/module/VuFind/src/VuFind/Session/HandlerInterface.php
@@ -30,7 +30,6 @@
 namespace VuFind\Session;
 
 use VuFind\Db\Table\DbTableAwareInterface;
-use Zend\Config\Config;
 use Zend\Session\SaveHandler\SaveHandlerInterface;
 
 /**
@@ -58,14 +57,4 @@ interface HandlerInterface extends SaveHandlerInterface, DbTableAwareInterface
      * @return void
      */
     public function disableWrites();
-
-    /**
-     * Set configuration.
-     *
-     * @param Config $config Session configuration ([Session] section of
-     * config.ini)
-     *
-     * @return void
-     */
-    public function setConfig(Config $config);
 }
diff --git a/module/VuFind/src/VuFind/Session/ManagerFactory.php b/module/VuFind/src/VuFind/Session/ManagerFactory.php
index 2995052263d..9b1a7d5e883 100644
--- a/module/VuFind/src/VuFind/Session/ManagerFactory.php
+++ b/module/VuFind/src/VuFind/Session/ManagerFactory.php
@@ -91,11 +91,8 @@ class ManagerFactory implements FactoryInterface
             throw new \Exception('Cannot initialize session; configuration missing');
         }
 
-        $sessionPluginManager = $container
-            ->get(\VuFind\Session\PluginManager::class);
-        $sessionHandler = $sessionPluginManager->get($config->Session->type);
-        $sessionHandler->setConfig($config->Session);
-        return $sessionHandler;
+        return $container->get(\VuFind\Session\PluginManager::class)
+            ->get($config->Session->type);
     }
 
     /**
diff --git a/module/VuFind/src/VuFind/Session/Memcache.php b/module/VuFind/src/VuFind/Session/Memcache.php
index 97b81134c6e..b3b3785520d 100644
--- a/module/VuFind/src/VuFind/Session/Memcache.php
+++ b/module/VuFind/src/VuFind/Session/Memcache.php
@@ -30,6 +30,8 @@
  */
 namespace VuFind\Session;
 
+use Zend\Config\Config;
+
 /**
  * Memcache session handler
  *
@@ -46,47 +48,44 @@ class Memcache extends AbstractBase
      *
      * @var \Memcache
      */
-    protected $connection = false;
+    protected $connection;
 
     /**
-     * Get connection to Memcache
+     * Constructor
      *
-     * @throws \Exception
-     * @return \Memcache
+     * @param Config    $config Session configuration ([Session] section of
+     * config.ini)
+     * @param \Memcache $client Optional Memcache client object
      */
-    public function getConnection()
+    public function __construct(Config $config = null, \Memcache $client = null)
     {
-        if (!$this->connection) {
-            // Set defaults if nothing set in config file.
-            $host = isset($this->config->memcache_host)
-                ? $this->config->memcache_host : 'localhost';
-            $port = isset($this->config->memcache_port)
-                ? $this->config->memcache_port : 11211;
-            $timeout = isset($this->config->memcache_connection_timeout)
-                ? $this->config->memcache_connection_timeout : 1;
+        parent::__construct($config);
+
+        // Set defaults if nothing set in config file.
+        $host = $config->memcache_host ?? 'localhost';
+        $port = $config->memcache_port ?? 11211;
+        $timeout = $config->memcache_connection_timeout ?? 1;
 
-            // Connect to Memcache:
-            $this->connection = new \Memcache();
-            if (!$this->connection->connect($host, $port, $timeout)) {
-                throw new \Exception(
-                    "Could not connect to Memcache (host = {$host}, port = {$port})."
-                );
-            }
+        // Connect to Memcache:
+        $this->connection = $client ?? new \Memcache();
+        if (!$this->connection->connect($host, $port, $timeout)) {
+            throw new \Exception(
+                "Could not connect to Memcache (host = {$host}, port = {$port})."
+            );
         }
-        return $this->connection;
     }
 
     /**
      * Read function must return string value always to make save handler work as
      * expected. Return empty string if there is no data to read.
      *
-     * @param string $sess_id The session ID to read
+     * @param string $sessId The session ID to read
      *
      * @return string
      */
-    public function read($sess_id)
+    public function read($sessId)
     {
-        $value = $this->getConnection()->get("vufind_sessions/{$sess_id}");
+        $value = $this->connection->get("vufind_sessions/{$sessId}");
         return empty($value) ? '' : $value;
     }
 
@@ -94,31 +93,31 @@ class Memcache extends AbstractBase
      * The destroy handler, this is executed when a session is destroyed with
      * session_destroy() and takes the session id as its only parameter.
      *
-     * @param string $sess_id The session ID to destroy
+     * @param string $sessId The session ID to destroy
      *
      * @return bool
      */
-    public function destroy($sess_id)
+    public function destroy($sessId)
     {
         // Perform standard actions required by all session methods:
-        parent::destroy($sess_id);
+        parent::destroy($sessId);
 
         // Perform Memcache-specific cleanup:
-        return $this->getConnection()->delete("vufind_sessions/{$sess_id}");
+        return $this->connection->delete("vufind_sessions/{$sessId}");
     }
 
     /**
      * A function that is called internally when session data is to be saved.
      *
-     * @param string $sess_id The current session ID
-     * @param string $data    The session data to write
+     * @param string $sessId The current session ID
+     * @param string $data   The session data to write
      *
      * @return bool
      */
-    protected function saveSession($sess_id, $data)
+    protected function saveSession($sessId, $data)
     {
-        return $this->getConnection()->set(
-            "vufind_sessions/{$sess_id}", $data, 0, $this->lifetime
+        return $this->connection->set(
+            "vufind_sessions/{$sessId}", $data, 0, $this->lifetime
         );
     }
 }
diff --git a/module/VuFind/src/VuFind/Session/PluginManager.php b/module/VuFind/src/VuFind/Session/PluginManager.php
index 6927865aa57..be84633a832 100644
--- a/module/VuFind/src/VuFind/Session/PluginManager.php
+++ b/module/VuFind/src/VuFind/Session/PluginManager.php
@@ -29,8 +29,6 @@
  */
 namespace VuFind\Session;
 
-use Zend\ServiceManager\Factory\InvokableFactory;
-
 /**
  * Session handler plugin manager
  *
@@ -65,10 +63,10 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
      * @var array
      */
     protected $factories = [
-        Database::class => InvokableFactory::class,
-        File::class => InvokableFactory::class,
-        Memcache::class => InvokableFactory::class,
-        Redis::class => InvokableFactory::class,
+        Database::class => AbstractBaseFactory::class,
+        File::class => AbstractBaseFactory::class,
+        Memcache::class => AbstractBaseFactory::class,
+        Redis::class => RedisFactory::class,
     ];
 
     /**
diff --git a/module/VuFind/src/VuFind/Session/Redis.php b/module/VuFind/src/VuFind/Session/Redis.php
index dc8645e4f8b..603a13f9f9b 100644
--- a/module/VuFind/src/VuFind/Session/Redis.php
+++ b/module/VuFind/src/VuFind/Session/Redis.php
@@ -31,6 +31,8 @@
  */
 namespace VuFind\Session;
 
+use Zend\Config\Config;
+
 /**
  * Redis session handler
  *
@@ -48,7 +50,7 @@ class Redis extends AbstractBase
      *
      * @var \Credis_Client
      */
-    protected $connection = false;
+    protected $connection;
 
     /**
      * Redis version
@@ -58,60 +60,45 @@ class Redis extends AbstractBase
     protected $redisVersion = 3;
 
     /**
-     * Get connection to Redis
+     * Constructor
      *
-     * @throws \Exception
-     * @return \Credis_Client
+     * @param \Credis_Client $connection Redis connection object
+     * @param Config         $config     Session configuration ([Session] section of
+     * config.ini)
      */
-    protected function getConnection()
+    public function __construct(\Credis_Client $connection, Config $config = null)
     {
-        if (!$this->connection) {
-            // Set defaults if nothing set in config file.
-            $host = $this->config->redis_host ?? 'localhost';
-            $port = $this->config->redis_port ?? 6379;
-            $timeout = $this->config->redis_connection_timeout ?? 0.5;
-            $auth = $this->config->redis_auth ?? false;
-            $redis_db = $this->config->redis_db ?? 0;
-            $this->redisVersion = (int)($this->config->redis_version ?? 3);
-            $standalone = (bool)($this->config->redis_standalone ?? true);
-
-            // Create Credis client, the connection is established lazily
-            $this->connection = new \Credis_Client(
-                $host, $port, $timeout, '', $redis_db, $auth
-            );
-            if ($standalone) {
-                $this->connection->forceStandalone();
-            }
-        }
-        return $this->connection;
+        parent::__construct($config);
+        $this->redisVersion = (int)($config->redis_version ?? 3);
+        $this->connection = $connection;
     }
 
     /**
      * Read function must return string value always to make save handler work as
      * expected. Return empty string if there is no data to read.
      *
-     * @param string $sess_id The session ID to read
+     * @param string $sessId The session ID to read
      *
      * @return string
      */
-    public function read($sess_id)
+    public function read($sessId)
     {
-        $session = $this->getConnection()->get("vufind_sessions/{$sess_id}");
+        $session = $this->connection->get("vufind_sessions/{$sessId}");
         return $session !== false ? $session : '';
     }
 
     /**
      * Write function that is called when session data is to be saved.
      *
-     * @param string $sess_id The current session ID
-     * @param string $data    The session data to write
+     * @param string $sessId The current session ID
+     * @param string $data   The session data to write
      *
      * @return bool
      */
-    protected function saveSession($sess_id, $data)
+    protected function saveSession($sessId, $data)
     {
-        return $this->getConnection()->setex(
-            "vufind_sessions/{$sess_id}", $this->lifetime, $data
+        return $this->connection->setex(
+            "vufind_sessions/{$sessId}", $this->lifetime, $data
         );
     }
 
@@ -119,21 +106,19 @@ class Redis extends AbstractBase
      * The destroy handler, this is executed when a session is destroyed with
      * session_destroy() and takes the session id as its only parameter.
      *
-     * @param string $sess_id The session ID to destroy
+     * @param string $sessId The session ID to destroy
      *
      * @return bool
      */
-    public function destroy($sess_id)
+    public function destroy($sessId)
     {
         // Perform standard actions required by all session methods:
-        parent::destroy($sess_id);
+        parent::destroy($sessId);
 
         // Perform Redis-specific cleanup
-        if ($this->redisVersion >= 4) {
-            $return = $this->getConnection()->unlink("vufind_sessions/{$sess_id}");
-        } else {
-            $return = $this->getConnection()->del("vufind_sessions/{$sess_id}");
-        }
-        return ($return > 0) ? true : false;
+        $unlinkMethod = ($this->redisVersion >= 4) ? 'unlink' : 'del';
+        $return = $this->connection->$unlinkMethod("vufind_sessions/{$sessId}");
+
+        return $return > 0;
     }
 }
diff --git a/module/VuFind/src/VuFind/Session/RedisFactory.php b/module/VuFind/src/VuFind/Session/RedisFactory.php
new file mode 100644
index 00000000000..49290a91ed8
--- /dev/null
+++ b/module/VuFind/src/VuFind/Session/RedisFactory.php
@@ -0,0 +1,95 @@
+<?php
+/**
+ * Generic factory for instantiating session handlers
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Session_Handlers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\Session;
+
+use Interop\Container\ContainerInterface;
+use Zend\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Generic factory for instantiating session handlers
+ *
+ * @category VuFind
+ * @package  Session_Handlers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ *
+ * @codeCoverageIgnore
+ */
+class RedisFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+
+        $config = $container->get(\VuFind\Config\PluginManager::class)
+            ->get('config')->Session ?? null;
+        return new $requestedName($this->getConnection($config), $config);
+    }
+
+    /**
+     * Given a configuration, build the client object.
+     *
+     * @param \Zend\Config\Config $config Session configuration
+     *
+     * @return \Credis_Client
+     */
+    protected function getConnection(\Zend\Config\Config $config)
+    {
+        // Set defaults if nothing set in config file.
+        $host = $config->redis_host ?? 'localhost';
+        $port = $config->redis_port ?? 6379;
+        $timeout = $config->redis_connection_timeout ?? 0.5;
+        $auth = $config->redis_auth ?? false;
+        $redisDb = $config->redis_db ?? 0;
+
+        // Create Credis client, the connection is established lazily
+        $client = new \Credis_Client($host, $port, $timeout, '', $redisDb, $auth);
+        if ((bool)($config->redis_standalone ?? true)) {
+            $client->forceStandalone();
+        }
+        return $client;
+    }
+}
diff --git a/module/VuFind/src/VuFindTest/Unit/SessionHandlerTestCase.php b/module/VuFind/src/VuFindTest/Unit/SessionHandlerTestCase.php
new file mode 100644
index 00000000000..b4eb57adcbb
--- /dev/null
+++ b/module/VuFind/src/VuFindTest/Unit/SessionHandlerTestCase.php
@@ -0,0 +1,108 @@
+<?php
+
+/**
+ * Abstract base class for session handler test cases.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Unit;
+
+use VuFind\Session\AbstractBase as SessionHandler;
+
+/**
+ * Abstract base class for session handler test cases.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+abstract class SessionHandlerTestCase extends TestCase
+{
+    /**
+     * Mock database tables.
+     *
+     * @var \VuFind\Db\Table\PluginManager
+     */
+    protected $tables = false;
+
+    /**
+     * Get mock database plugin manager
+     *
+     * @return \VuFind\Db\Table\PluginManager
+     */
+    protected function getTables()
+    {
+        if (!$this->tables) {
+            $this->tables = $this
+                ->getMockBuilder(\VuFind\Db\Table\PluginManager::class)
+                ->disableOriginalConstructor()
+                ->getMock();
+        }
+        return $this->tables;
+    }
+
+    /**
+     * Set up mock databases for a session handler.
+     *
+     * @param SessionHandler $handler Session handler
+     *
+     * @return void
+     */
+    protected function injectMockDatabaseTables(SessionHandler $handler)
+    {
+        $handler->setDbTableManager($this->getTables());
+    }
+
+    /**
+     * Set up expectations for the standard abstract handler's destroy behavior.
+     *
+     * @param string $sessId Session ID that we expect will be destroyed.
+     *
+     * @return void
+     */
+    protected function setUpDestroyExpectations($sessId)
+    {
+        $search = $this->getMockBuilder(\VuFind\Db\Table\Search::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $search->expects($this->once())
+            ->method('destroySession')
+            ->with($this->equalTo($sessId));
+        $external = $this->getMockBuilder(\VuFind\Db\Table\ExternalSession::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $external->expects($this->once())
+            ->method('destroySession')
+            ->with($this->equalTo($sessId));
+        $tables = $this->getTables();
+        $tables->expects($this->at(0))->method('get')
+            ->with($this->equalTo('Search'))
+            ->will($this->returnValue($search));
+        $tables->expects($this->at(1))->method('get')
+            ->with($this->equalTo('ExternalSession'))
+            ->will($this->returnValue($external));
+    }
+}
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/Session/DatabaseTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/Session/DatabaseTest.php
new file mode 100644
index 00000000000..ae02e611566
--- /dev/null
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/Session/DatabaseTest.php
@@ -0,0 +1,158 @@
+<?php
+/**
+ * Database Session Handler Test Class
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Session;
+
+use VuFind\Session\Database;
+
+/**
+ * Database Session Handler Test Class
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class DatabaseTest extends \VuFindTest\Unit\SessionHandlerTestCase
+{
+    /**
+     * Test reading a session from the database.
+     *
+     * @return void
+     */
+    public function testRead()
+    {
+        $handler = $this->getHandler();
+        $session = $this->getMockSessionTable();
+        $session->expects($this->once())->method('readSession')
+            ->with($this->equalTo('foo'), $this->equalTo(3600))
+            ->will($this->returnValue('bar'));
+        $this->getTables()->expects($this->once())->method('get')
+            ->with($this->equalTo('Session'))
+            ->will($this->returnValue($session));
+        $this->assertEquals('bar', $handler->read('foo'));
+    }
+
+    /**
+     * Test reading a session from the database with a non-default lifetime config.
+     *
+     * @return void
+     */
+    public function testReadWithNonDefaultLifetime()
+    {
+        $handler = $this->getHandler(
+            new \Zend\Config\Config(['lifetime' => 1000])
+        );
+        $session = $this->getMockSessionTable();
+        $session->expects($this->once())->method('readSession')
+            ->with($this->equalTo('foo'), $this->equalTo(1000))
+            ->will($this->returnValue('bar'));
+        $this->getTables()->expects($this->once())->method('get')
+            ->with($this->equalTo('Session'))
+            ->will($this->returnValue($session));
+        $this->assertEquals('bar', $handler->read('foo'));
+    }
+
+    /**
+     * Test garbage collection.
+     *
+     * @return void
+     */
+    public function testGc()
+    {
+        $handler = $this->getHandler();
+        $session = $this->getMockSessionTable();
+        $session->expects($this->once())->method('garbageCollect')
+            ->with($this->equalTo(3600));
+        $this->getTables()->expects($this->once())->method('get')
+            ->with($this->equalTo('Session'))
+            ->will($this->returnValue($session));
+        $this->assertTrue($handler->gc(3600));
+    }
+
+    /**
+     * Test writing a session.
+     *
+     * @return void
+     */
+    public function testWrite()
+    {
+        $handler = $this->getHandler();
+        $session = $this->getMockSessionTable();
+        $session->expects($this->once())->method('writeSession')
+            ->with($this->equalTo('foo'), $this->equalTo('stuff'));
+        $this->getTables()->expects($this->once())->method('get')
+            ->with($this->equalTo('Session'))
+            ->will($this->returnValue($session));
+        $this->assertTrue($handler->write('foo', 'stuff'));
+    }
+
+    /**
+     * Test destroying a session.
+     *
+     * @return void
+     */
+    public function testDestroy()
+    {
+        $handler = $this->getHandler();
+        $this->setUpDestroyExpectations('foo');
+        $session = $this->getMockSessionTable();
+        $session->expects($this->once())->method('destroySession')
+            ->with($this->equalTo('foo'));
+        $this->getTables()->expects($this->at(2))->method('get')
+            ->with($this->equalTo('Session'))
+            ->will($this->returnValue($session));
+        $this->assertTrue($handler->destroy('foo'));
+    }
+
+    /**
+     * Get the session handler to test.
+     *
+     * @param \Zend\Config\Config $config Optional configuration
+     *
+     * @return Database
+     */
+    protected function getHandler($config = null)
+    {
+        $handler = new Database($config);
+        $this->injectMockDatabaseTables($handler);
+        return $handler;
+    }
+
+    /**
+     * Get a mock session table.
+     *
+     * @return \VuFind\Db\Table\Session
+     */
+    protected function getMockSessionTable()
+    {
+        return $this->getMockBuilder(\VuFind\Db\Table\Session::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+}
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/Session/FileTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/Session/FileTest.php
new file mode 100644
index 00000000000..1e51b0d01bf
--- /dev/null
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/Session/FileTest.php
@@ -0,0 +1,144 @@
+<?php
+/**
+ * File Session Handler Test Class
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Session;
+
+use VuFind\Session\File;
+
+/**
+ * File Session Handler Test Class
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class FileTest extends \VuFindTest\Unit\SessionHandlerTestCase
+{
+    /**
+     * Path to session files
+     *
+     * @var string
+     */
+    protected $path;
+
+    /**
+     * Generic setup method
+     *
+     * @return void
+     */
+    public function setUp()
+    {
+        $tempdir = function_exists('sys_get_temp_dir')
+            ? sys_get_temp_dir() : DIRECTORY_SEPARATOR . 'tmp';
+        $this->path = $tempdir . DIRECTORY_SEPARATOR . 'vufindtest_sessions';
+    }
+
+    /**
+     * Generic teardown method
+     *
+     * @return void
+     */
+    public function tearDown()
+    {
+        rmdir($this->path);
+    }
+
+    /**
+     * Test the standard default session life cycle.
+     *
+     * @return void
+     */
+    public function testWriteReadAndDestroy()
+    {
+        $handler = $this->getHandler();
+        $this->assertTrue($handler->write('foo', 'bar'));
+        $this->assertEquals('bar', $handler->read('foo'));
+        $this->setUpDestroyExpectations('foo');
+        $this->assertTrue($handler->destroy('foo'));
+        $this->assertEquals('', $handler->read('foo'));
+    }
+
+    /**
+     * Test disabling writes.
+     *
+     * @return void
+     */
+    public function testDisabledWrites()
+    {
+        $handler = $this->getHandler();
+        $handler->disableWrites();
+        $this->assertTrue($handler->write('foo', 'bar'));
+        $this->assertEquals('', $handler->read('foo'));
+
+        // Now test re-enabling writes:
+        $handler->enableWrites();
+        $this->assertTrue($handler->write('foo', 'bar'));
+        $this->assertEquals('bar', $handler->read('foo'));
+
+        // Now clean up after ourselves:
+        $this->setUpDestroyExpectations('foo');
+        $this->assertTrue($handler->destroy('foo'));
+        $this->assertEquals('', $handler->read('foo'));
+    }
+
+    /**
+     * Test the session garbage collector.
+     *
+     * @return void
+     */
+    public function testGarbageCollector()
+    {
+        $handler = $this->getHandler();
+        $this->assertTrue($handler->write('foo', 'bar'));
+        $this->assertEquals('bar', $handler->read('foo'));
+        // Use a negative garbage collection age so we can purge everything
+        // without having to wait for time to pass in the test!
+        $this->assertTrue($handler->gc(-1));
+        $this->assertEquals('', $handler->read('foo'));
+    }
+
+    /**
+     * Get the session handler to test.
+     *
+     * @param \Zend\Config\Config $config Optional configuration
+     *
+     * @return Database
+     */
+    protected function getHandler($config = null)
+    {
+        if (null === $config) {
+            $config = new \Zend\Config\Config(
+                ['file_save_path' => $this->path]
+            );
+        }
+        $handler = new File($config);
+        $this->injectMockDatabaseTables($handler);
+        return $handler;
+    }
+}
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/Session/MemcacheTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/Session/MemcacheTest.php
new file mode 100644
index 00000000000..b996ced0894
--- /dev/null
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/Session/MemcacheTest.php
@@ -0,0 +1,159 @@
+<?php
+/**
+ * Memcache Session Handler Test Class
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Session;
+
+use VuFind\Session\Memcache;
+
+/**
+ * Memcache Session Handler Test Class
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class MemcacheTest extends \VuFindTest\Unit\SessionHandlerTestCase
+{
+    /**
+     * Test reading a session from the database.
+     *
+     * @return void
+     */
+    public function testRead()
+    {
+        $memcache = $this->getMockBuilder(\Memcache::class)
+            ->setMethods(['connect', 'get'])
+            ->getMock();
+        $memcache->expects($this->once())->method('connect')
+            ->will($this->returnValue(true));
+        $memcache->expects($this->once())->method('get')
+            ->with($this->equalTo('vufind_sessions/foo'))
+            ->will($this->returnValue('bar'));
+        $handler = $this->getHandler(null, $memcache);
+        $this->assertEquals('bar', $handler->read('foo'));
+    }
+
+    /**
+     * Test writing a session with default configs.
+     *
+     * @return void
+     */
+    public function testWriteWithDefaults()
+    {
+        $memcache = $this->getMockBuilder(\Memcache::class)
+            ->setMethods(['connect', 'set'])
+            ->getMock();
+        $memcache->expects($this->once())->method('connect')
+            ->with(
+                $this->equalTo('localhost'),
+                $this->equalTo(11211),
+                $this->equalTo(1)
+            )->will($this->returnValue(true));
+        $memcache->expects($this->once())->method('set')
+            ->with(
+                $this->equalTo('vufind_sessions/foo'),
+                $this->equalTo('stuff'),
+                $this->equalTo(0),
+                $this->equalTo(3600)
+            )->will($this->returnValue(true));
+        $handler = $this->getHandler(null, $memcache);
+        $this->assertTrue($handler->write('foo', 'stuff'));
+    }
+
+    /**
+     * Test writing a session with non-default configs.
+     *
+     * @return void
+     */
+    public function testWriteWithNonDefaults()
+    {
+        $memcache = $this->getMockBuilder(\Memcache::class)
+            ->setMethods(['connect', 'set'])
+            ->getMock();
+        $memcache->expects($this->once())->method('connect')
+            ->with(
+                $this->equalTo('myhost'),
+                $this->equalTo(1234),
+                $this->equalTo(2)
+            )->will($this->returnValue(true));
+        $memcache->expects($this->once())->method('set')
+            ->with(
+                $this->equalTo('vufind_sessions/foo'),
+                $this->equalTo('stuff'),
+                $this->equalTo(0),
+                $this->equalTo(1000)
+            )->will($this->returnValue(true));
+        $config = new \Zend\Config\Config(
+            [
+                'lifetime' => 1000,
+                'memcache_host' => 'myhost',
+                'memcache_port' => 1234,
+                'memcache_connection_timeout' => 2,
+            ]
+        );
+        $handler = $this->getHandler($config, $memcache);
+        $this->assertTrue($handler->write('foo', 'stuff'));
+    }
+
+    /**
+     * Test destroying a session.
+     *
+     * @return void
+     */
+    public function testDestroy()
+    {
+        $memcache = $this->getMockBuilder(\Memcache::class)
+            ->setMethods(['connect', 'delete'])
+            ->getMock();
+        $memcache->expects($this->once())->method('connect')
+            ->will($this->returnValue(true));
+        $memcache->expects($this->once())->method('delete')
+            ->with($this->equalTo('vufind_sessions/foo'))
+            ->will($this->returnValue(true));
+        $handler = $this->getHandler(null, $memcache);
+        $this->setUpDestroyExpectations('foo');
+
+        $this->assertTrue($handler->destroy('foo'));
+    }
+
+    /**
+     * Get the session handler to test.
+     *
+     * @param \Zend\Config\Config $config Optional configuration
+     * @param \Memcache           $client Optional client object
+     *
+     * @return Database
+     */
+    protected function getHandler($config = null, $client = null)
+    {
+        $handler = new Memcache($config, $client);
+        $this->injectMockDatabaseTables($handler);
+        return $handler;
+    }
+}
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/Session/RedisTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/Session/RedisTest.php
new file mode 100644
index 00000000000..f3849a2aee1
--- /dev/null
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/Session/RedisTest.php
@@ -0,0 +1,136 @@
+<?php
+/**
+ * Redis Session Handler Test Class
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Session;
+
+use VuFind\Session\Redis;
+
+/**
+ * Redis Session Handler Test Class
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class RedisTest extends \VuFindTest\Unit\SessionHandlerTestCase
+{
+    /**
+     * Test reading a session from the database.
+     *
+     * @return void
+     */
+    public function testRead()
+    {
+        $client = $this->getMockBuilder(\Credis_Client::class)
+            ->setMethods(['get'])
+            ->getMock();
+        $client->expects($this->once())->method('get')
+            ->with($this->equalTo('vufind_sessions/foo'))
+            ->will($this->returnValue('bar'));
+        $handler = $this->getHandler($client);
+        $this->assertEquals('bar', $handler->read('foo'));
+    }
+
+    /**
+     * Test writing a session with default configs.
+     *
+     * @return void
+     */
+    public function testWrite()
+    {
+        $client = $this->getMockBuilder(\Credis_Client::class)
+            ->setMethods(['setex'])
+            ->getMock();
+        $client->expects($this->once())->method('setex')
+            ->with(
+                $this->equalTo('vufind_sessions/foo'),
+                $this->equalTo(3600),
+                $this->equalTo('stuff')
+            )
+            ->will($this->returnValue(true));
+        $handler = $this->getHandler($client);
+        $this->assertTrue($handler->write('foo', 'stuff'));
+    }
+
+    /**
+     * Test destroying a session with default (Redis version 3) support.
+     *
+     * @return void
+     */
+    public function testDestroyDefault()
+    {
+        $client = $this->getMockBuilder(\Credis_Client::class)
+            ->setMethods(['del'])
+            ->getMock();
+        $client->expects($this->once())->method('del')
+            ->with($this->equalTo('vufind_sessions/foo'))
+            ->will($this->returnValue(1));
+        $handler = $this->getHandler($client);
+        $this->setUpDestroyExpectations('foo');
+
+        $this->assertTrue($handler->destroy('foo'));
+    }
+
+    /**
+     * Test destroying a session with newer (Redis version 4+) support.
+     *
+     * @return void
+     */
+    public function testDestroyNewRedis()
+    {
+        $client = $this->getMockBuilder(\Credis_Client::class)
+            ->setMethods(['unlink'])
+            ->getMock();
+        $client->expects($this->once())->method('unlink')
+            ->with($this->equalTo('vufind_sessions/foo'))
+            ->will($this->returnValue(1));
+        $config = new \Zend\Config\Config(
+            ['redis_version' => 4]
+        );
+        $handler = $this->getHandler($client, $config);
+        $this->setUpDestroyExpectations('foo');
+
+        $this->assertTrue($handler->destroy('foo'));
+    }
+
+    /**
+     * Get the session handler to test.
+     *
+     * @param \Credis_Client      $client Client object
+     * @param \Zend\Config\Config $config Optional configuration
+     *
+     * @return Database
+     */
+    protected function getHandler($client, $config = null)
+    {
+        $handler = new Redis($client, $config);
+        $this->injectMockDatabaseTables($handler);
+        return $handler;
+    }
+}
-- 
GitLab