From 404a36489dd557b720e9fc9c17b9fa18dd08cfb7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Lahmann?= <lahmann@users.noreply.github.com>
Date: Mon, 12 Sep 2016 18:04:05 +0200
Subject: [PATCH] Updated DAIA and new PAIA ILS-driver (#729)

---
 config/vufind/DAIA.ini                        |   21 +
 config/vufind/PAIA.ini                        |   49 +
 config/vufind/config.ini                      |    4 +-
 module/VuFind/config/module.config.php        |    1 +
 module/VuFind/src/VuFind/ILS/Driver/DAIA.php  |  520 ++++-
 .../VuFind/src/VuFind/ILS/Driver/Factory.php  |   29 +-
 module/VuFind/src/VuFind/ILS/Driver/PAIA.php  | 1764 +++++++++++++++++
 .../paia/response/changePassword.json         |   15 +
 .../tests/fixtures/paia/response/fees.json    |   51 +
 .../tests/fixtures/paia/response/items.json   |   87 +
 .../tests/fixtures/paia/response/login.json   |   18 +
 .../fixtures/paia/response/login_bad.json     |   14 +
 .../tests/fixtures/paia/response/patron.json  |   22 +
 .../paia/response/patron_expired.json         |   22 +
 .../fixtures/paia/response/patron_locked.json |   22 +
 .../fixtures/paia/response/renew_error.json   |   17 +
 .../fixtures/paia/response/renew_ok.json      |   30 +
 .../paia/response/storageretrieval.json       |   29 +
 .../src/VuFindTest/ILS/Driver/DAIATest.php    |   53 +-
 .../src/VuFindTest/ILS/Driver/PAIATest.php    |  628 ++++++
 20 files changed, 3287 insertions(+), 109 deletions(-)
 create mode 100644 config/vufind/PAIA.ini
 create mode 100644 module/VuFind/src/VuFind/ILS/Driver/PAIA.php
 create mode 100644 module/VuFind/tests/fixtures/paia/response/changePassword.json
 create mode 100644 module/VuFind/tests/fixtures/paia/response/fees.json
 create mode 100644 module/VuFind/tests/fixtures/paia/response/items.json
 create mode 100644 module/VuFind/tests/fixtures/paia/response/login.json
 create mode 100644 module/VuFind/tests/fixtures/paia/response/login_bad.json
 create mode 100644 module/VuFind/tests/fixtures/paia/response/patron.json
 create mode 100644 module/VuFind/tests/fixtures/paia/response/patron_expired.json
 create mode 100644 module/VuFind/tests/fixtures/paia/response/patron_locked.json
 create mode 100644 module/VuFind/tests/fixtures/paia/response/renew_error.json
 create mode 100644 module/VuFind/tests/fixtures/paia/response/renew_ok.json
 create mode 100644 module/VuFind/tests/fixtures/paia/response/storageretrieval.json
 create mode 100644 module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/PAIATest.php

diff --git a/config/vufind/DAIA.ini b/config/vufind/DAIA.ini
index aae070fdde5..2986e6b32ea 100644
--- a/config/vufind/DAIA.ini
+++ b/config/vufind/DAIA.ini
@@ -14,6 +14,12 @@
 ; daiaResponseFormat = json
 ;
 
+; This section contains settings applying to DAIA and its deriving classes such as
+; PAIA.
+;[General]
+; Set the time to live (ttl) for cached data (default is 30 seconds).
+;cacheLifetime = 90
+
 [DAIA]
 ; The base URL for the DAIA webservice.
 baseUrl = [your DAIA server base url]
@@ -44,3 +50,18 @@ daiaContentTypes['xml'] = "application/xml"
 ; (separate multiple values by commas, for example:
 ; daiaContentTypes['json'] = "application/json, application/javascript"
 daiaContentTypes['json'] = "application/json"
+
+; Enable caching for DAIA items (default is false).
+;daiaCache = false
+
+; DAIA does not support placing holds (this functionality is covered by PAIA) but is
+; able to provide a link to the OPAC to perform such an action. Regarding placing
+; holds/recalls such link is usually given as href for an unavailable service.
+; Uncomment the below section Holds with the setting 'function' to show a link to
+; the OPAC (if it was provided in the DAIA response) instead of a VuFind
+; Holds-Button.
+; If PAIA is used in combination with the DAIA driver, handling holds etc. should be
+; left to the logic in the PAIA driver - thus keep this section commented out if you
+; are using PAIA as well.
+; [Holds]
+; function = getHoldLink
diff --git a/config/vufind/PAIA.ini b/config/vufind/PAIA.ini
new file mode 100644
index 00000000000..dd7ade3a47e
--- /dev/null
+++ b/config/vufind/PAIA.ini
@@ -0,0 +1,49 @@
+; The PAIA driver extends the DAIA driver and uses the settings for the DAIA driver.
+; Either copy & paste the settings from DAIA.ini into this PAIA.ini or reference your
+; correctly configured DAIA.ini via [Parent_Config]
+;[Parent_Config]
+;relative_path = DAIA.ini
+
+; PAIA configuration
+[PAIA]
+; base URL of the PAIA server WITH trailing slash
+baseUrl = ""
+
+; Enable caching for PAIA items (default is false). TTL for cached data will be the
+; same as for DAIA cache (see cacheLifetime setting in DAIA.ini).
+;paiaCache = false
+
+; Driver configuration, usually you can leave it untouched
+
+; Without customization the PAIA driver will offer to place a recall for items with
+; unavailable service loan but set href for loan. The recall will be performed via
+; PAIA request.
+; The pre-defined HMACKeys (id:item_id:doc_id) should suffice to place a recall. No
+; extra fields are allowed (if you need those you might be able to cover this
+; functionality in a custom driver by using PAIA confirm/conditions).
+[Holds]
+; HMACKeys - A list of form element names that will be analyzed for consistency
+; during form processing. Most users should not need to change this setting.
+HMACKeys = "id:item_id:doc_id"
+
+; Without customization the PAIA driver will offer to place a storageretrievalrequest
+; for items with available service loan and set href for loan. The
+; storageretrievalrequest will be performed via PAIA request (technically the same as
+; for recall, but with different frontend templates etc.).
+; The pre-defined HMACKeys (id:item_id:doc_id) should suffice to request an item. No
+; extra fields are allowed (if you need those you might be able to cover this
+; functionality in a custom driver by using PAIA confirm/conditions).
+[StorageRetrievalRequests]
+; HMACKeys - A list of form element names that will be analyzed for consistency
+; during form processing. Most users should not need to change this setting.
+HMACKeys = id:item_id:doc_id
+
+; The PAIA driver supports renewals in MyResearch views. The renewal will be
+; performed via PAIA renew.
+; The pre-defined HMACKeys (id:item_id:doc_id) should suffice to renew an item. No
+; extra fields are allowed (if you need those you might be able to cover this
+; functionality in a custom driver by using PAIA confirm/conditions).
+[Renewals]
+; HMACKeys - A list of form element names that will be analyzed for consistency
+; during form processing. Most users should not need to change this setting.
+HMACKeys = "id:item_id:doc_id"
diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index a2259287231..bfb9ceaa587 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -146,11 +146,11 @@ lifetime                    = 3600 ; Session lasts for 1 hour
 
 ; Please set the ILS that VuFind will interact with.
 ;
-; Available drivers: Aleph, Amicus, ClaviusSQL, Evergreen, Horizon (basic database
+; Available drivers: Aleph, Amicus, ClaviusSQL, DAIA, Evergreen, Horizon (basic db
 ;       access only), HorizonXMLAPI (more features via API), Innovative, Koha,
 ;       KohaILSDI, LBS4, MultiBackend (to chain together multiple drivers in a
 ;       consortial setting), NewGenLib, NoILS (for users without an ILS, or to
-;       disable ILS functionality during maintenance), Polaris, Unicorn (which
+;       disable ILS functionality during maintenance), PAIA, Polaris, Unicorn (which
 ;       also applies to SirsiDynix Symphony), Virtua, Voyager (for Voyager 6+),
 ;       VoyagerRestful (for Voyager 7+ w/ RESTful web services), XCNCIP2 (for XC
 ;       NCIP Tookit v2.x)
diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index fc98dae4e1b..12c50d8f86f 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -389,6 +389,7 @@ $config = [
                     'lbs4' => 'VuFind\ILS\Driver\Factory::getLBS4',
                     'multibackend' => 'VuFind\ILS\Driver\Factory::getMultiBackend',
                     'noils' => 'VuFind\ILS\Driver\Factory::getNoILS',
+                    'paia' => 'VuFind\ILS\Driver\Factory::getPAIA',
                     'kohailsdi' => 'VuFind\ILS\Driver\Factory::getKohaILSDI',
                     'unicorn' => 'VuFind\ILS\Driver\Factory::getUnicorn',
                     'voyager' => 'VuFind\ILS\Driver\Factory::getVoyager',
diff --git a/module/VuFind/src/VuFind/ILS/Driver/DAIA.php b/module/VuFind/src/VuFind/ILS/Driver/DAIA.php
index 23dc74cf688..2395dbc0dac 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/DAIA.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/DAIA.php
@@ -59,6 +59,13 @@ class DAIA extends AbstractBase implements
      */
     protected $baseUrl;
 
+    /**
+     * Flag to switch on/off caching for DAIA items
+     *
+     * @var bool
+     */
+    protected $daiaCacheEnabled = false;
+
     /**
      * DAIA query identifier prefix
      *
@@ -159,6 +166,35 @@ class DAIA extends AbstractBase implements
         } else {
             $this->debug('No ContentTypes for response defined. Accepting any.');
         }
+        if (isset($this->config['DAIA']['daiaCache'])) {
+            $this->daiaCacheEnabled = $this->config['DAIA']['daiaCache'];
+        } else {
+            $this->debug('Caching not enabled, disabling it by default.');
+        }
+        if (isset($this->config['General'])
+            && isset($this->config['General']['cacheLifetime'])
+        ) {
+            $this->cacheLifetime = $this->config['General']['cacheLifetime'];
+        } else {
+            $this->debug(
+                'Cache lifetime not set, using VuFind\ILS\Driver\AbstractBase ' .
+                'default value.'
+            );
+        }
+    }
+
+    /**
+     * DAIA specific override of method to ensure uniform cache keys for cached
+     * VuFind objects.
+     *
+     * @param string|null $suffix Optional suffix that will get appended to the
+     * object class name calling getCacheKey()
+     *
+     * @return string
+     */
+    protected function getCacheKey($suffix = null)
+    {
+        return parent::getCacheKey(md5($this->baseURL) . $suffix);
     }
 
     /**
@@ -208,6 +244,15 @@ class DAIA extends AbstractBase implements
      */
     public function getStatus($id)
     {
+        // check ids for existing availability data in cache and skip these ids
+        if ($this->daiaCacheEnabled
+            && $item = $this->getCachedData($this->generateURI($id))
+        ) {
+            if ($item != null) {
+                return $item;
+            }
+        }
+
         // let's retrieve the DAIA document by URI
         try {
             $rawResult = $this->doHTTPRequest($this->generateURI($id));
@@ -216,7 +261,12 @@ class DAIA extends AbstractBase implements
             $doc = $this->extractDaiaDoc($id, $rawResult);
             if (!is_null($doc)) {
                 // parse the extracted DAIA document and return the status info
-                return $this->parseDaiaDoc($id, $doc);
+                $data = $this->parseDaiaDoc($id, $doc);
+                // cache the status information
+                if ($this->daiaCacheEnabled) {
+                    $this->putCachedData($this->generateURI($id), $data);
+                }
+                return $data;
             }
         } catch (ILSException $e) {
             $this->debug($e->getMessage());
@@ -247,41 +297,66 @@ class DAIA extends AbstractBase implements
     {
         $status = [];
 
-        try {
-            if ($this->multiQuery) {
-                // perform one DAIA query with multiple URIs
-                $rawResult = $this
-                    ->doHTTPRequest($this->generateMultiURIs($ids));
-                // the id used in VuFind can differ from the document-URI
-                // (depending on how the URI is generated)
-                foreach ($ids as $id) {
-                    // it is assumed that each DAIA document has a unique URI,
-                    // so get the document with the corresponding id
-                    $doc = $this->extractDaiaDoc($id, $rawResult);
-                    if (!is_null($doc)) {
-                        // a document with the corresponding id exists, which
-                        // means we got status information for that record
-                        $status[] = $this->parseDaiaDoc($id, $doc);
-                    }
-                    unset($doc);
+        // check cache for given ids and skip these ids if availability data is found
+        foreach ($ids as $key => $id) {
+            if ($this->daiaCacheEnabled
+                && $item = $this->getCachedData($this->generateURI($id))
+            ) {
+                if ($item != null) {
+                    $status[] = $item;
+                    unset($ids[$key]);
                 }
-            } else {
-                // multiQuery is not supported, so retrieve DAIA documents one by
-                // one
-                foreach ($ids as $id) {
-                    $rawResult = $this->doHTTPRequest($this->generateURI($id));
-                    // extract the DAIA document for the current id from the
-                    // HTTPRequest's result
-                    $doc = $this->extractDaiaDoc($id, $rawResult);
-                    if (!is_null($doc)) {
-                        // parse the extracted DAIA document and save the status
-                        // info
-                        $status[] = $this->parseDaiaDoc($id, $doc);
+            }
+        }
+
+        // only query DAIA service if we have some ids left
+        if (count($ids) > 0) {
+            try {
+                if ($this->multiQuery) {
+                    // perform one DAIA query with multiple URIs
+                    $rawResult = $this
+                        ->doHTTPRequest($this->generateMultiURIs($ids));
+                    // the id used in VuFind can differ from the document-URI
+                    // (depending on how the URI is generated)
+                    foreach ($ids as $id) {
+                        // it is assumed that each DAIA document has a unique URI,
+                        // so get the document with the corresponding id
+                        $doc = $this->extractDaiaDoc($id, $rawResult);
+                        if (!is_null($doc)) {
+                            // a document with the corresponding id exists, which
+                            // means we got status information for that record
+                            $data = $this->parseDaiaDoc($id, $doc);
+                            // cache the status information
+                            if ($this->daiaCacheEnabled) {
+                                $this->putCachedData($this->generateURI($id), $data);
+                            }
+                            $status[] = $data;
+                        }
+                        unset($doc);
+                    }
+                } else {
+                    // multiQuery is not supported, so retrieve DAIA documents one by
+                    // one
+                    foreach ($ids as $id) {
+                        $rawResult = $this->doHTTPRequest($this->generateURI($id));
+                        // extract the DAIA document for the current id from the
+                        // HTTPRequest's result
+                        $doc = $this->extractDaiaDoc($id, $rawResult);
+                        if (!is_null($doc)) {
+                            // parse the extracted DAIA document and save the status
+                            // info
+                            $data = $this->parseDaiaDoc($id, $doc);
+                            // cache the status information
+                            if ($this->daiaCacheEnabled) {
+                                $this->putCachedData($this->generateURI($id), $data);
+                            }
+                            $status[] = $data;
+                        }
                     }
                 }
+            } catch (ILSException $e) {
+                $this->debug($e->getMessage());
             }
-        } catch (ILSException $e) {
-            $this->debug($e->getMessage());
         }
         return $status;
     }
@@ -430,7 +505,7 @@ class DAIA extends AbstractBase implements
      *
      * @return string     URI of the DAIA document
      *
-     * @see http://gbv.github.io/daiaspec/daia.html#query-api
+     * @see http://gbv.github.io/daia/daia.html#query-parameters
      */
     protected function generateURI($id)
     {
@@ -445,7 +520,7 @@ class DAIA extends AbstractBase implements
      *
      * @return string   Combined URIs (delimited by "|")
      *
-     * @see http://gbv.github.io/daiaspec/daia.html#query-api
+     * @see http://gbv.github.io/daia/daia.html#query-parameters
      */
     protected function generateMultiURIs($ids)
     {
@@ -657,6 +732,8 @@ class DAIA extends AbstractBase implements
             foreach ($daiaArray['item'] as $item) {
                 $result_item = [];
                 $result_item['id'] = $id;
+                // custom DAIA field
+                $result_item['doc_id'] = $doc_id;
                 $result_item['item_id'] = $item['id'];
                 // custom DAIA field used in getHoldLink()
                 $result_item['ilslink']
@@ -671,9 +748,17 @@ class DAIA extends AbstractBase implements
                 // get callnumber
                 $result_item['callnumber'] = $this->getItemCallnumber($item);
                 // get location
-                $result_item['location'] = $this->getItemLocation($item);
+                $result_item['location'] = $this->getItemDepartment($item);
+                // custom DAIA field
+                $result_item['locationid'] = $this->getItemDepartmentId($item);
                 // get location link
-                $result_item['locationhref'] = $this->getItemLocationLink($item);
+                $result_item['locationhref'] = $this->getItemDepartmentLink($item);
+                // custom DAIA field
+                $result_item['storage'] = $this->getItemStorage($item);
+                // custom DAIA field
+                $result_item['storageid'] = $this->getItemStorageId($item);
+                // custom DAIA field
+                $result_item['storagehref'] = $this->getItemStorageLink($item);
                 // status and availability will be calculated in own function
                 $result_item = $this->getItemStatus($item) + $result_item;
                 // add result_item to the result array
@@ -696,9 +781,10 @@ class DAIA extends AbstractBase implements
         $availability = false;
         $status = ''; // status cannot be null as this will crash the translator
         $duedate = null;
-        $availableLink = '';
+        $serviceLink = '';
         $queue = '';
         $item_notes = [];
+        $item_limitation_types = [];
         $services = [];
 
         if (isset($item['available'])) {
@@ -720,11 +806,11 @@ class DAIA extends AbstractBase implements
                     // openaccess
                     $availability = true;
                     if ($available['service'] == 'loan'
-                        && isset($available['service']['href'])
+                        && isset($available['href'])
                     ) {
                         // save the link to the ils if we have a href for loan
                         // service
-                        $availableLink = $available['service']['href'];
+                        $serviceLink = $available['href'];
                     }
                 }
 
@@ -732,7 +818,11 @@ class DAIA extends AbstractBase implements
                 if (isset($available['limitation'])) {
                     $item_notes = array_merge(
                         $item_notes,
-                        $this->getItemLimitation($available['limitation'])
+                        $this->getItemLimitationContent($available['limitation'])
+                    );
+                    $item_limitation_types = array_merge(
+                        $item_limitation_types,
+                        $this->getItemLimitationTypes($available['limitation'])
                     );
                 }
 
@@ -758,16 +848,23 @@ class DAIA extends AbstractBase implements
                     )
                 ) {
                     if ($unavailable['service'] == 'loan'
-                        && isset($unavailable['service']['href'])
+                        && isset($unavailable['href'])
                     ) {
                         //save the link to the ils if we have a href for loan service
+                        $serviceLink = $unavailable['href'];
                     }
 
                     // use limitation element for status string
                     if (isset($unavailable['limitation'])) {
                         $item_notes = array_merge(
                             $item_notes,
-                            $this->getItemLimitation($unavailable['limitation'])
+                            $this->getItemLimitationContent(
+                                $unavailable['limitation']
+                            )
+                        );
+                        $item_limitation_types = array_merge(
+                            $item_limitation_types,
+                            $this->getItemLimitationTypes($unavailable['limitation'])
                         );
                     }
                 }
@@ -796,119 +893,280 @@ class DAIA extends AbstractBase implements
             }
         }
 
-        /*'availability' => '0',
-        'status' => '',  // string - needs to be computed from availability info
-        'duedate' => '', // if checked_out else null
-        'returnDate' => '', // false if not recently returned(?)
-        'requests_placed' => '', // total number of placed holds
-        'is_holdable' => false, // place holding possible?*/
+        /*'returnDate' => '', // false if not recently returned(?)*/
 
-        if (!empty($availableLink)) {
-            $return['ilslink'] = $availableLink;
+        if (!empty($serviceLink)) {
+            $return['ilslink'] = $serviceLink;
         }
 
         $return['item_notes']      = $item_notes;
-        $return['status']          = $status;
+        $return['status']          = $this->getStatusString($item);
         $return['availability']    = $availability;
         $return['duedate']         = $duedate;
         $return['requests_placed'] = $queue;
         $return['services']        = $this->getAvailableItemServices($services);
 
+        // In this DAIA driver implementation addLink and is_holdable are assumed
+        // Boolean as patron based availability requires either a patron-id or -type.
+        // This should be handled in a custom DAIA driver
+        $return['addLink']     = $this->checkIsRecallable($item);
+        $return['is_holdable'] = $this->checkIsRecallable($item);
+        $return['holdtype']    = $this->getHoldType($item);
+
+        // Check if we the item is available for storage retrieval request if it is
+        // not holdable.
+        $return['addStorageRetrievalRequestLink'] = !$return['is_holdable']
+            ? $this->checkIsStorageRetrievalRequest($item) : false;
+
+        // add a custom Field to allow passing custom DAIA data to the frontend in
+        // order to use it for more precise display of availability
+        $return['customData']      = $this->getCustomData($item);
+
+        $return['limitation_types'] = $item_limitation_types;
+        
         return $return;
     }
 
     /**
-     * Returns the value for "number" in VuFind getStatus/getHolding array
+     * Helper function to allow custom data in status array.
      *
-     * @param array $item    Array with DAIA item data
-     * @param int   $counter Integer counting items as alternative return value
+     * @param array $item Array with DAIA item data
      *
-     * @return mixed
+     * @return array
      */
-    protected function getItemNumber($item, $counter)
+    protected function getCustomData($item)
     {
-        return $counter;
+        return [];
     }
 
     /**
-     * Returns the value for "barcode" in VuFind getStatus/getHolding array
+     * Helper function to return an appropriate status string for current item.
      *
      * @param array $item Array with DAIA item data
      *
      * @return string
      */
-    protected function getItemBarcode($item)
+    protected function getStatusString($item)
     {
-        return '1';
+        // status cannot be null as this will crash the translator
+        return '';
     }
 
     /**
-     * Returns the value for "reserve" in VuFind getStatus/getHolding array
+     * Helper function to determine if item is recallable.
+     * DAIA does not genuinly allow distinguishing between holdable and recallable
+     * items. This could be achieved by usage of limitations but this would not be
+     * shared functionality between different DAIA implementations (thus should be
+     * implemented in custom drivers). Therefore this returns whether an item
+     * is recallable based on unavailable services and the existence of an href.
      *
      * @param array $item Array with DAIA item data
      *
-     * @return string
+     * @return bool
      */
-    protected function getItemReserveStatus($item)
+    protected function checkIsRecallable($item)
     {
-        return 'N';
+        // This basic implementation checks the item for being unavailable for loan
+        // and presentation but with an existing href (as a flag for further action).
+        $services = ['available' => [], 'unavailable' => []];
+        $href = false;
+        if (isset($item['available'])) {
+            // check if item is loanable or presentation
+            foreach ($item['available'] as $available) {
+                if (isset($available['service'])
+                    && in_array($available['service'], ['loan', 'presentation'])
+                ) {
+                    $services['available'][] = $available['service'];
+                }
+            }
+        }
+
+        if (isset($item['unavailable'])) {
+            foreach ($item['unavailable'] as $unavailable) {
+                if (isset($unavailable['service'])
+                    && in_array($unavailable['service'], ['loan', 'presentation'])
+                ) {
+                    $services['unavailable'][] = $unavailable['service'];
+                    // attribute href is used to determine whether item is recallable
+                    // or not
+                    $href = isset($unavailable['href']) ? true : $href;
+                }
+            }
+        }
+
+        // Check if we have at least one service unavailable and a href field is set
+        // (either as flag or as actual value for the next action).
+        return ($href && count(
+            array_diff($services['unavailable'], $services['available'])
+        ));
     }
 
     /**
-     * Returns the value for "callnumber" in VuFind getStatus/getHolding array
+     * Helper function to determine if the item is available as storage retrieval.
+     *
+     * @param array $item Array with DAIA item data
+     *
+     * @return bool
+     */
+    protected function checkIsStorageRetrievalRequest($item)
+    {
+        // This basic implementation checks the item for being available for loan
+        // and presentation but with an existing href (as a flag for further action).
+        $services = ['available' => [], 'unavailable' => []];
+        $href = false;
+        if (isset($item['available'])) {
+            // check if item is loanable or presentation
+            foreach ($item['available'] as $available) {
+                if (isset($available['service'])
+                    && in_array($available['service'], ['loan', 'presentation'])
+                ) {
+                    $services['available'][] = $available['service'];
+                    // attribute href is used to determine whether item is
+                    // requestable or not
+                    $href = isset($available['href']) ? true : $href;
+                }
+            }
+        }
+
+        if (isset($item['unavailable'])) {
+            foreach ($item['unavailable'] as $unavailable) {
+                if (isset($unavailable['service'])
+                    && in_array($unavailable['service'], ['loan', 'presentation'])
+                ) {
+                    $services['unavailable'][] = $unavailable['service'];
+                }
+            }
+        }
+
+        // Check if we have at least one service unavailable and a href field is set
+        // (either as flag or as actual value for the next action).
+        return ($href && count(
+            array_diff($services['available'], $services['unavailable'])
+        ));
+    }
+
+    /**
+     * Helper function to determine the holdtype availble for current item.
+     * DAIA does not genuinly allow distinguishing between holdable and recallable
+     * items. This could be achieved by usage of limitations but this would not be
+     * shared functionality between different DAIA implementations (thus should be
+     * implemented in custom drivers). Therefore getHoldType always returns recall.
+     *
+     * @param array $item Array with DAIA item data
+     *
+     * @return string 'recall'|null
+     */
+    protected function getHoldType($item)
+    {
+        // return holdtype (hold, recall or block if patron is not allowed) for item
+        return $this->checkIsRecallable($item) ? 'recall' : null;
+    }
+
+    /**
+     * Returns the evaluated value of the provided limitation element
+     *
+     * @param array $limitations Array with DAIA limitation data
+     *
+     * @return array
+     */
+    protected function getItemLimitation($limitations)
+    {
+        $itemLimitation = [];
+        foreach ($limitations as $limitation) {
+            // return the first limitation with content set
+            if (isset($limitation['content'])) {
+                $itemLimitation[] = $limitation['content'];
+            }
+        }
+        return $itemLimitation;
+    }
+
+    /**
+     * Returns the value of item.department.content (e.g. to be used in VuFind
+     * getStatus/getHolding array as location)
      *
      * @param array $item Array with DAIA item data
      *
      * @return string
      */
-    protected function getItemCallnumber($item)
+    protected function getItemDepartment($item)
     {
-        return isset($item['label']) && !empty($item['label'])
-            ? $item['label']
+        return isset($item['department']) && isset($item['department']['content'])
+        && !empty($item['department']['content'])
+            ? $item['department']['content']
             : 'Unknown';
     }
 
     /**
-     * Returns the value for "location" in VuFind getStatus/getHolding array
+     * Returns the value of item.department.id (e.g. to be used in VuFind
+     * getStatus/getHolding array as location)
      *
      * @param array $item Array with DAIA item data
      *
      * @return string
      */
-    protected function getItemLocation($item)
+    protected function getItemDepartmentId($item)
     {
-        $location = '';
+        return isset($item['department']) && isset($item['department']['id'])
+            ? $item['department']['id'] : '';
+    }
 
-        if (isset($item['department'])
-            && isset($item['department']['content'])
-        ) {
-            $location .= (empty($location)
-                ? $item['department']['content']
-                : ' - ' . $item['department']['content']);
-        }
+    /**
+     * Returns the value of item.department.href (e.g. to be used in VuFind
+     * getStatus/getHolding array for linking the location)
+     *
+     * @param array $item Array with DAIA item data
+     *
+     * @return string
+     */
+    protected function getItemDepartmentLink($item)
+    {
+        return isset($item['department']['href'])
+            ? $item['department']['href'] : false;
+    }
 
-        if (isset($item['storage'])
-            && isset($item['storage']['content'])
-        ) {
-            $location .= (empty($location)
-                ? $item['storage']['content']
-                : ' - ' . $item['storage']['content']);
-        }
+    /**
+     * Returns the value of item.storage.content (e.g. to be used in VuFind
+     * getStatus/getHolding array as location)
+     *
+     * @param array $item Array with DAIA item data
+     *
+     * @return string
+     */
+    protected function getItemStorage($item)
+    {
+        return isset($item['storage']) && isset($item['storage']['content'])
+        && !empty($item['storage']['content'])
+            ? $item['storage']['content']
+            : 'Unknown';
+    }
 
-        return (empty($location) ? 'Unknown' : $location);
+    /**
+     * Returns the value of item.storage.id (e.g. to be used in VuFind
+     * getStatus/getHolding array as location)
+     *
+     * @param array $item Array with DAIA item data
+     *
+     * @return string
+     */
+    protected function getItemStorageId($item)
+    {
+        return isset($item['storage']) && isset($item['storage']['id'])
+            ? $item['storage']['id'] : '';
     }
 
     /**
-     * Returns the value for "location" href in VuFind getStatus/getHolding array
+     * Returns the value of item.storage.href (e.g. to be used in VuFind
+     * getStatus/getHolding array for linking the location)
      *
      * @param array $item Array with DAIA item data
      *
      * @return string
      */
-    protected function getItemLocationLink($item)
+    protected function getItemStorageLink($item)
     {
-        return isset($item['storage']['href'])
-            ? $item['storage']['href'] : false;
+        return isset($item['storage']) && isset($item['storage']['href'])
+            ? $item['storage']['href'] : '';
     }
 
     /**
@@ -918,16 +1176,86 @@ class DAIA extends AbstractBase implements
      *
      * @return array
      */
-    protected function getItemLimitation($limitations)
+    protected function getItemLimitationContent($limitations)
     {
-        $itemLimitation = [];
+        $itemLimitationContent = [];
         foreach ($limitations as $limitation) {
-            // return the limitations with content set
+            // return the first limitation with content set
             if (isset($limitation['content'])) {
-                $itemLimitation[] = $limitation['content'];
+                $itemLimitationContent[] = $limitation['content'];
             }
         }
-        return $itemLimitation;
+        return $itemLimitationContent;
+    }
+
+    /**
+     * Returns the evaluated values of the provided limitations element
+     *
+     * @param array $limitations Array with DAIA limitation data
+     *
+     * @return array
+     */
+    protected function getItemLimitationTypes($limitations)
+    {
+        $itemLimitationTypes = [];
+        foreach ($limitations as $limitation) {
+            // return the first limitation with content set
+            if (isset($limitation['id'])) {
+                $itemLimitationTypes[] = $limitation['id'];
+            }
+        }
+        return $itemLimitationTypes;
+    }
+
+    /**
+     * Returns the value for "number" in VuFind getStatus/getHolding array
+     *
+     * @param array $item    Array with DAIA item data
+     * @param int   $counter Integer counting items as alternative return value
+     *
+     * @return mixed
+     */
+    protected function getItemNumber($item, $counter)
+    {
+        return $counter;
+    }
+
+    /**
+     * Returns the value for "location" in VuFind getStatus/getHolding array
+     *
+     * @param array $item Array with DAIA item data
+     *
+     * @return string
+     */
+    protected function getItemBarcode($item)
+    {
+        return '1';
+    }
+
+    /**
+     * Returns the value for "reserve" in VuFind getStatus/getHolding array
+     *
+     * @param array $item Array with DAIA item data
+     *
+     * @return string
+     */
+    protected function getItemReserveStatus($item)
+    {
+        return 'N';
+    }
+
+    /**
+     * Returns the value for "callnumber" in VuFind getStatus/getHolding array
+     *
+     * @param array $item Array with DAIA item data
+     *
+     * @return string
+     */
+    protected function getItemCallnumber($item)
+    {
+        return isset($item['label']) && !empty($item['label'])
+            ? $item['label']
+            : 'Unknown';
     }
 
     /**
@@ -952,7 +1280,7 @@ class DAIA extends AbstractBase implements
         }
         return array_intersect(['loan', 'presentation'], $availableServices);
     }
-    
+
     /**
      * Logs content of message elements in DAIA response for debugging
      *
diff --git a/module/VuFind/src/VuFind/ILS/Driver/Factory.php b/module/VuFind/src/VuFind/ILS/Driver/Factory.php
index 251cc697b31..8d6cdd8a21a 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/Factory.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/Factory.php
@@ -65,9 +65,15 @@ class Factory
      */
     public static function getDAIA(ServiceManager $sm)
     {
-        return new DAIA(
+        $daia = new DAIA(
             $sm->getServiceLocator()->get('VuFind\DateConverter')
         );
+
+        $daia->setCacheStorage(
+            $sm->getServiceLocator()->get('VuFind\CacheManager')->getCache('object')
+        );
+
+        return $daia;
     }
 
     /**
@@ -156,6 +162,27 @@ class Factory
         return new NoILS($sm->getServiceLocator()->get('VuFind\RecordLoader'));
     }
 
+    /**
+     * Factory for PAIA driver.
+     *
+     * @param ServiceManager $sm Service manager.
+     *
+     * @return PAIA
+     */
+    public static function getPAIA(ServiceManager $sm)
+    {
+        $paia = new PAIA(
+            $sm->getServiceLocator()->get('VuFind\DateConverter'),
+            $sm->getServiceLocator()->get('VuFind\SessionManager')
+        );
+
+        $paia->setCacheStorage(
+            $sm->getServiceLocator()->get('VuFind\CacheManager')->getCache('object')
+        );
+
+        return $paia;
+    }
+
     /**
      * Factory for KohaILSDI driver.
      *
diff --git a/module/VuFind/src/VuFind/ILS/Driver/PAIA.php b/module/VuFind/src/VuFind/ILS/Driver/PAIA.php
new file mode 100644
index 00000000000..df079cbaaee
--- /dev/null
+++ b/module/VuFind/src/VuFind/ILS/Driver/PAIA.php
@@ -0,0 +1,1764 @@
+<?php
+/**
+ * PAIA ILS Driver for VuFind to get patron information
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Oliver Goldschmidt, Magda Roos, Till Kinstler, André Lahmann 2013,
+ * 2014, 2015.
+ *
+ * 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., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ *
+ * @category VuFind
+ * @package  ILS_Drivers
+ * @author   Oliver Goldschmidt <o.goldschmidt@tuhh.de>
+ * @author   Magdalena Roos <roos@gbv.de>
+ * @author   Till Kinstler <kinstler@gbv.de>
+ * @author   André Lahmann <lahmann@ub.uni-leipzig.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
+ */
+namespace VuFind\ILS\Driver;
+use VuFind\Exception\ILS as ILSException;
+
+/**
+ * PAIA ILS Driver for VuFind to get patron information
+ *
+ * Holding information is obtained by DAIA, so it's not necessary to implement those
+ * functions here; we just need to extend the DAIA driver.
+ *
+ * @category VuFind
+ * @package  ILS_Drivers
+ * @author   Oliver Goldschmidt <o.goldschmidt@tuhh.de>
+ * @author   Magdalena Roos <roos@gbv.de>
+ * @author   Till Kinstler <kinstler@gbv.de>
+ * @author   André Lahmann <lahmann@ub.uni-leipzig.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
+ */
+class PAIA extends DAIA
+{
+    /**
+     * URL of PAIA service
+     *
+     * @var
+     */
+    protected $paiaURL;
+
+    /**
+     * Flag to switch on/off caching for PAIA items
+     *
+     * @var bool
+     */
+    protected $paiaCacheEnabled = false;
+
+    /**
+     * Session containing PAIA login information
+     *
+     * @var \Zend\Session\Container
+     */
+    protected $session;
+
+    /**
+     * SessionManager
+     *
+     * @var \VuFind\SessionManager
+     */
+    protected $sessionManager;
+
+    /**
+     * PAIA status strings
+     *
+     * @var array
+     */
+    protected static $statusStrings = [
+        '0' => 'no relation',
+        '1' => 'reserved',
+        '2' => 'ordered',
+        '3' => 'held',
+        '4' => 'provided',
+        '5' => 'rejected',
+    ];
+
+    /**
+     * PAIA constructor.
+     *
+     * @param \VuFind\Date\Converter       $converter      Date converter
+     * @param \Zend\Session\SessionManager $sessionManager Session Manager
+     */
+    public function __construct(\VuFind\Date\Converter $converter,
+        \Zend\Session\SessionManager $sessionManager
+    ) {
+        parent::__construct($converter);
+        $this->sessionManager = $sessionManager;
+    }
+
+    /**
+     * PAIA specific override of method to ensure uniform cache keys for cached
+     * VuFind objects.
+     *
+     * @param string|null $suffix Optional suffix that will get appended to the
+     * object class name calling getCacheKey()
+     *
+     * @return string
+     */
+    protected function getCacheKey($suffix = null)
+    {
+        return \VuFind\ILS\Driver\AbstractBase::getCacheKey(
+            md5($this->baseUrl . $this->paiaURL) . $suffix
+        );
+    }
+
+    /**
+     * Get the session container (constructing it on demand if not already present)
+     *
+     * @return SessionContainer
+     */
+    protected function getSession()
+    {
+        // SessionContainer not defined yet? Build it now:
+        if (null === $this->session) {
+            $this->session = new \Zend\Session\Container(
+                'PAIA', $this->sessionManager
+            );
+        }
+        return $this->session;
+    }
+
+    /**
+     * Get the session scope
+     *
+     * @return array Array of the Session scope
+     */
+    protected function getScope()
+    {
+        return $this->getSession()->scope;
+    }
+
+    /**
+     * Initialize the driver.
+     *
+     * Validate configuration and perform all resource-intensive tasks needed to
+     * make the driver active.
+     *
+     * @throws ILSException
+     * @return void
+     */
+    public function init()
+    {
+        parent::init();
+
+        if (!(isset($this->config['PAIA']['baseUrl']))) {
+            throw new ILSException('PAIA/baseUrl configuration needs to be set.');
+        }
+        $this->paiaURL = $this->config['PAIA']['baseUrl'];
+
+        // do we have caching enabled for PAIA
+        if (isset($this->config['PAIA']['paiaCache'])) {
+            $this->paiaCacheEnabled = $this->config['PAIA']['paiaCache'];
+        } else {
+            $this->debug('Caching not enabled, disabling it by default.');
+        }
+    }
+
+    // public functions implemented to satisfy Driver Interface
+
+    /*
+    These methods are not implemented in the PAIA driver as they are probably
+    not necessary in PAIA context:
+    - findReserves
+    - getCancelHoldLink
+    - getConsortialHoldings
+    - getCourses
+    - getDepartments
+    - getHoldDefaultRequiredDate
+    - getInstructors
+    - getOfflineMode
+    - getSuppressedAuthorityRecords
+    - getSuppressedRecords
+    - hasHoldings
+    - loginIsHidden
+    - renewMyItemsLink
+    - supportsMethod
+    */
+
+    /**
+     * This method cancels a list of holds for a specific patron.
+     *
+     * @param array $cancelDetails An associative array with two keys:
+     *      patron   array returned by the driver's patronLogin method
+     *      details  an array of strings returned by the driver's
+     *               getCancelHoldDetails method
+     *
+     * @return array Associative array containing:
+     *      count   The number of items successfully cancelled
+     *      items   Associative array where key matches one of the item_id
+     *              values returned by getMyHolds and the value is an
+     *              associative array with these keys:
+     *                success    Boolean true or false
+     *                status     A status message from the language file
+     *                           (required – VuFind-specific message,
+     *                           subject to translation)
+     *                sysMessage A system supplied failure message
+     */
+    public function cancelHolds($cancelDetails)
+    {
+        $it = $cancelDetails['details'];
+        $items = [];
+        foreach ($it as $item) {
+            $items[] = ['item' => stripslashes($item)];
+        }
+        $patron = $cancelDetails['patron'];
+        $post_data = ["doc" => $items];
+
+        try {
+            $array_response = $this->paiaPostAsArray(
+                'core/' . $patron['cat_username'] . '/cancel', $post_data
+            );
+        } catch (ILSException $e) {
+            $this->debug($e->getMessage());
+            return [
+                'success' => false,
+                'status' => $e->getMessage(),
+            ];
+        }
+
+        $details = [];
+
+        if (isset($array_response['error'])) {
+            $details[] = [
+                'success' => false,
+                'status' => $array_response['error_description'],
+                'sysMessage' => $array_response['error']
+            ];
+        } else {
+            $count = 0;
+            $elements = $array_response['doc'];
+            foreach ($elements as $element) {
+                $item_id = $element['item'];
+                if ($element['error']) {
+                    $details[$item_id] = [
+                        'success' => false,
+                        'status' => $element['error'],
+                        'sysMessage' => 'Cancel request rejected'
+                    ];
+                } else {
+                    $details[$item_id] = [
+                        'success' => true,
+                        'status' => 'Success',
+                        'sysMessage' => 'Successfully cancelled'
+                    ];
+                    $count++;
+
+                    // DAIA cache cannot be cleared for particular item as PAIA only
+                    // operates with specific item URIs and the DAIA cache is setup
+                    // by doc URIs (containing items with URIs)
+                }
+            }
+
+            // If caching is enabled for PAIA clear the cache as at least for one
+            // item cancel was successfull and therefore the status changed.
+            // Otherwise the changed status will not be shown before the cache
+            // expires.
+            if ($this->paiaCacheEnabled) {
+                $this->removeCachedData($patron['cat_username']);
+            }
+        }
+        $returnArray = ['count' => $count, 'items' => $details];
+
+        return $returnArray;
+    }
+
+    /**
+     * Public Function which changes the password in the library system
+     * (not supported prior to VuFind 2.4)
+     *
+     * @param array $details Array with patron information, newPassword and
+     *                       oldPassword.
+     *
+     * @return array An array with patron information.
+     */
+    public function changePassword($details)
+    {
+        $post_data = [
+            "patron"       => $details['patron']['cat_username'],
+            "username"     => $details['patron']['cat_username'],
+            "old_password" => $details['oldPassword'],
+            "new_password" => $details['newPassword']
+        ];
+
+        try {
+            $array_response = $this->paiaPostAsArray(
+                'auth/change', $post_data
+            );
+        } catch (ILSException $e) {
+            $this->debug($e->getMessage());
+            return [
+                'success' => false,
+                'status' => $e->getMessage(),
+            ];
+        }
+
+        $details = [];
+
+        if (isset($array_response['error'])) {
+            // on error
+            $details = [
+                'success'    => false,
+                'status'     => $array_response['error'],
+                'sysMessage' =>
+                    isset($array_response['error'])
+                        ? $array_response['error'] : ' ' .
+                    isset($array_response['error_description'])
+                        ? $array_response['error_description'] : ' '
+            ];
+        } elseif ($array_response['patron'] === $post_data['patron']) {
+            // on success patron_id is returned
+            $details = [
+                'success' => true,
+                'status' => 'Successfully changed'
+            ];
+        } else {
+            $details = [
+                'success' => false,
+                'status' => 'Failure changing password',
+                'sysMessage' => serialize($array_response)
+            ];
+        }
+        return $details;
+    }
+
+    /**
+     * This method returns a string to use as the input form value for
+     * cancelling each hold item. (optional, but required if you
+     * implement cancelHolds). Not supported prior to VuFind 1.2
+     *
+     * @param array $checkOutDetails One of the individual item arrays returned by
+     *                               the getMyHolds method
+     *
+     * @return string  A string to use as the input form value for cancelling
+     *                 each hold item; you can pass any data that is needed
+     *                 by your ILS to identify the hold – the output of this
+     *                 method will be used as part of the input to the
+     *                 cancelHolds method.
+     */
+    public function getCancelHoldDetails($checkOutDetails)
+    {
+        return($checkOutDetails['cancel_details']);
+    }
+
+    /**
+     * Get Default Pick Up Location
+     *
+     * @param array $patron      Patron information returned by the patronLogin
+     * method.
+     * @param array $holdDetails Optional array, only passed in when getting a list
+     * in the context of placing a hold; contains most of the same values passed to
+     * placeHold, minus the patron data.  May be used to limit the pickup options
+     * or may be ignored.
+     *
+     * @return string       The default pickup location for the patron.
+     */
+    public function getDefaultPickUpLocation($patron = null, $holdDetails = null)
+    {
+        return false;
+    }
+
+    /**
+     * Get Funds
+     *
+     * Return a list of funds which may be used to limit the getNewItems list.
+     *
+     * @return array An associative array with key = fund ID, value = fund name.
+     */
+    public function getFunds()
+    {
+        // If you do not want or support such limits, just return an empty
+        // array here and the limit control on the new item search screen
+        // will disappear.
+        return [];
+    }
+
+    /**
+     * Cancel Storage Retrieval Request
+     *
+     * Attempts to Cancel a Storage Retrieval Request on a particular item. The
+     * data in $cancelDetails['details'] is determined by
+     * getCancelStorageRetrievalRequestDetails().
+     *
+     * @param array $cancelDetails An array of item and patron data
+     *
+     * @return array               An array of data on each request including
+     * whether or not it was successful and a system message (if available)
+     */
+    public function cancelStorageRetrievalRequests($cancelDetails)
+    {
+        // Not yet implemented
+        return [];
+    }
+
+    /**
+     * Get Cancel Storage Retrieval Request Details
+     *
+     * In order to cancel a hold, Voyager requires the patron details an item ID
+     * and a recall ID. This function returns the item id and recall id as a string
+     * separated by a pipe, which is then submitted as form data in Hold.php. This
+     * value is then extracted by the CancelHolds function.
+     *
+     * @param array $details An array of item data
+     *
+     * @return string Data for use in a form field
+     */
+    public function getCancelStorageRetrievalRequestDetails($details)
+    {
+        // Not yet implemented
+        return '';
+    }
+
+    /**
+     * Get Patron ILL Requests
+     *
+     * This is responsible for retrieving all ILL requests by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     *
+     * @return mixed        Array of the patron's ILL requests
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getMyILLRequests($patron)
+    {
+        // Not yet implemented
+        return [];
+    }
+
+    /**
+     * Check if ILL request available
+     *
+     * This is responsible for determining if an item is requestable
+     *
+     * @param string $id     The Bib ID
+     * @param array  $data   An Array of item data
+     * @param patron $patron An array of patron data
+     *
+     * @return bool True if request is valid, false if not
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function checkILLRequestIsValid($id, $data, $patron)
+    {
+        // Not yet implemented
+        return false;
+    }
+    /**
+     * Place ILL Request
+     *
+     * Attempts to place an ILL request on a particular item and returns
+     * an array with result details
+     *
+     * @param array $details An array of item and patron data
+     *
+     * @return mixed An array of data on the request including
+     * whether or not it was successful and a system message (if available)
+     */
+    public function placeILLRequest($details)
+    {
+        // Not yet implemented
+        return [];
+    }
+
+    /**
+     * Get ILL Pickup Libraries
+     *
+     * This is responsible for getting information on the possible pickup libraries
+     *
+     * @param string $id     Record ID
+     * @param array  $patron Patron
+     *
+     * @return bool|array False if request not allowed, or an array of associative
+     * arrays with libraries.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getILLPickupLibraries($id, $patron)
+    {
+        // Not yet implemented
+        return false;
+    }
+
+    /**
+     * Get ILL Pickup Locations
+     *
+     * This is responsible for getting a list of possible pickup locations for a
+     * library
+     *
+     * @param string $id        Record ID
+     * @param string $pickupLib Pickup library ID
+     * @param array  $patron    Patron
+     *
+     * @return bool|array False if request not allowed, or an array of locations.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getILLPickupLocations($id, $pickupLib, $patron)
+    {
+        // Not yet implemented
+        return false;
+    }
+
+    /**
+     * Cancel ILL Request
+     *
+     * Attempts to Cancel an ILL request on a particular item. The
+     * data in $cancelDetails['details'] is determined by
+     * getCancelILLRequestDetails().
+     *
+     * @param array $cancelDetails An array of item and patron data
+     *
+     * @return array               An array of data on each request including
+     * whether or not it was successful and a system message (if available)
+     */
+    public function cancelILLRequests($cancelDetails)
+    {
+        // Not yet implemented
+        return [];
+    }
+
+    /**
+     * Get Cancel ILL Request Details
+     *
+     * @param array $details An array of item data
+     *
+     * @return string Data for use in a form field
+     */
+    public function getCancelILLRequestDetails($details)
+    {
+        // Not yet implemented
+        return '';
+    }
+
+    /**
+     * Get Patron Fines
+     *
+     * This is responsible for retrieving all fines by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     *
+     * @return mixed Array of the patron's fines on success
+     */
+    public function getMyFines($patron)
+    {
+        $fees = $this->paiaGetAsArray(
+            'core/' . $patron['cat_username'] . '/fees'
+        );
+
+        // PAIA simple data type money: a monetary value with currency (format
+        // [0-9]+\.[0-9][0-9] [A-Z][A-Z][A-Z]), for instance 0.80 USD.
+        $feeConverter = function ($fee) {
+            $paiaCurrencyPattern = "/^([0-9]+\.[0-9][0-9]) ([A-Z][A-Z][A-Z])$/";
+            if (preg_match($paiaCurrencyPattern, $fee, $feeMatches)) {
+                // VuFind expects fees in PENNIES
+                return ($feeMatches[1] * 100);
+            }
+            return $fee;
+        };
+
+        $results = [];
+        if (isset($fees['fee'])) {
+            foreach ($fees['fee'] as $fee) {
+                $result = [
+                    // fee.amount 	1..1 	money 	amount of a single fee
+                    'amount'      => $feeConverter($fee['amount']),
+                    'checkout'    => '',
+                    // fee.feetype 	0..1 	string 	textual description of the type
+                    // of service that caused the fee
+                    'fine'    => (isset($fee['feetype']) ? $fee['feetype'] : null),
+                    'balance' => $feeConverter($fee['amount']),
+                    // fee.date 	0..1 	date 	date when the fee was claimed
+                    'createdate'  => (isset($fee['date'])
+                        ? $this->convertDate($fee['date']) : null),
+                    'duedate' => '',
+                    // fee.edition 	0..1 	URI 	edition that caused the fee
+                    'id' => (isset($fee['edition'])
+                        ? $this->getAlternativeItemId($fee['edition']) : ''),
+                ];
+                // custom PAIA fields can get added in getAdditionalFeeData
+                $results[] = $result + $this->getAdditionalFeeData($fee, $patron);
+            }
+        }
+        return $results;
+    }
+
+    /**
+     * Gets additional array fields for the item.
+     * Override this method in your custom PAIA driver if necessary.
+     *
+     * @param array $fee    The fee array from PAIA
+     * @param array $patron The patron array from patronLogin
+     *
+     * @return array Additional fee data for the item
+     */
+    protected function getAdditionalFeeData($fee, $patron = null)
+    {
+        $additionalData = [];
+        // Add the item title using the about field,
+        // but only if this fee is caused by some item
+        if (isset($fee['item'])) {
+            $additionalData['title'] = $fee['about'];
+        }
+
+        // custom PAIA fields
+        // fee.about 	0..1 	string 	textual information about the fee
+        // fee.item 	0..1 	URI 	item that caused the fee
+        // fee.feeid 	0..1 	URI 	URI of the type of service that
+        // caused the fee
+        $additionalData['feeid']      = (isset($fee['feeid'])
+            ? $fee['feeid'] : null);
+        $additionalData['about']      = (isset($fee['about'])
+            ? $fee['about'] : null);
+        $additionalData['item']       = (isset($fee['item'])
+            ? $fee['item'] : null);
+        $additionalData['title']      = (isset($fee['title'])
+            ? $fee['title'] : null);
+
+        return $additionalData;
+    }
+
+    /**
+     * Get Patron Holds
+     *
+     * This is responsible for retrieving all holds by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     *
+     * @return mixed Array of the patron's holds on success.
+     */
+    public function getMyHolds($patron)
+    {
+        // filters for getMyHolds are:
+        // status = 1 - reserved (the document is not accessible for the patron yet,
+        //              but it will be)
+        //          4 - provided (the document is ready to be used by the patron)
+        $filter = ['status' => [1, 4]];
+        // get items-docs for given filters
+        $items = $this->paiaGetItems($patron, $filter);
+
+        return $this->mapPaiaItems($items, 'myHoldsMapping');
+    }
+
+    /**
+     * Get Patron Profile
+     *
+     * This is responsible for retrieving the profile for a specific patron.
+     *
+     * @param array $patron The patron array
+     *
+     * @return array Array of the patron's profile data on success,
+     */
+    public function getMyProfile($patron)
+    {
+        //todo: read VCard if avaiable in patron info
+        //todo: make fields more configurable
+        if (is_array($patron)) {
+            return [
+                'firstname'  => $patron['firstname'],
+                'lastname'   => $patron['lastname'],
+                'address1'   => null,
+                'address2'   => null,
+                'city'       => null,
+                'country'    => null,
+                'zip'        => null,
+                'phone'      => null,
+                'group'      => null,
+                // PAIA specific custom values
+                'expires'    => isset($patron['expires'])
+                    ? $this->convertDate($patron['expires']) : null,
+                'statuscode' => isset($patron['status']) ? $patron['status'] : null,
+                'canWrite'   => in_array('write_items', $this->getScope()),
+            ];
+        }
+        return [];
+    }
+
+    /**
+     * Get Patron Transactions
+     *
+     * This is responsible for retrieving all transactions (i.e. checked out items)
+     * by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     *
+     * @return array Array of the patron's transactions on success,
+     */
+    public function getMyTransactions($patron)
+    {
+        // filters for getMyTransactions are:
+        // status = 3 - held (the document is on loan by the patron)
+        $filter = ['status' => [3]];
+        // get items-docs for given filters
+        $items = $this->paiaGetItems($patron, $filter);
+
+        return $this->mapPaiaItems($items, 'myTransactionsMapping');
+    }
+
+    /**
+     * Get Patron StorageRetrievalRequests
+     *
+     * This is responsible for retrieving all storage retrieval requests
+     * by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     *
+     * @return array Array of the patron's storage retrieval requests on success,
+     */
+    public function getMyStorageRetrievalRequests($patron)
+    {
+        // filters for getMyStorageRetrievalRequests are:
+        // status = 2 - ordered (the document is ordered by the patron)
+        $filter = ['status' => [2]];
+        // get items-docs for given filters
+        $items = $this->paiaGetItems($patron, $filter);
+
+        return $this->mapPaiaItems($items, 'myStorageRetrievalRequestsMapping');
+    }
+
+    /**
+     * This method queries the ILS for new items
+     *
+     * @param string $page    page number of results to retrieve (counting starts @1)
+     * @param string $limit   the size of each page of results to retrieve
+     * @param string $daysOld the maximum age of records to retrieve in days (max 30)
+     * @param string $fundID  optional fund ID to use for limiting results
+     *
+     * @return array An associative array with two keys: 'count' (the number of items
+     * in the 'results' array) and 'results' (an array of associative arrays, each
+     * with a single key: 'id', a record ID).
+     */
+    public function getNewItems($page, $limit, $daysOld, $fundID)
+    {
+        return [];
+    }
+
+    /**
+     * Get Pick Up Locations
+     *
+     * This is responsible for gettting a list of valid library locations for
+     * holds / recall retrieval
+     *
+     * @param array $patron      Patron information returned by the patronLogin
+     *                           method.
+     * @param array $holdDetails Optional array, only passed in when getting a list
+     * in the context of placing a hold; contains most of the same values passed to
+     * placeHold, minus the patron data.  May be used to limit the pickup options
+     * or may be ignored.  The driver must not add new options to the return array
+     * based on this data or other areas of VuFind may behave incorrectly.
+     *
+     * @return array        An array of associative arrays with locationID and
+     * locationDisplay keys
+     */
+    public function getPickUpLocations($patron = null, $holdDetails = null)
+    {
+        // How to get valid PickupLocations for a PICA LBS?
+        return [];
+    }
+
+    /**
+     * This method returns a string to use as the input form value for renewing
+     * each hold item. (optional, but required if you implement the
+     * renewMyItems method) Not supported prior to VuFind 1.2
+     *
+     * @param array $checkOutDetails One of the individual item arrays returned by
+     *                               the getMyTransactions method
+     *
+     * @return string A string to use as the input form value for renewing
+     *                each item; you can pass any data that is needed by your
+     *                ILS to identify the transaction to renew – the output
+     *                of this method will be used as part of the input to the
+     *                renewMyItems method.
+     */
+    public function getRenewDetails($checkOutDetails)
+    {
+        return($checkOutDetails['renew_details']);
+    }
+
+    /**
+     * Get the callnumber of this item
+     *
+     * @param array $doc Array of PAIA item.
+     *
+     * @return String
+     */
+    protected function getCallNumber($doc)
+    {
+        return isset($doc['label']) ? $doc['label'] : null;
+    }
+
+    /**
+     * Patron Login
+     *
+     * This is responsible for authenticating a patron against the catalog.
+     *
+     * @param string $username The patron's username
+     * @param string $password The patron's login password
+     *
+     * @return mixed          Associative array of patron info on successful login,
+     * null on unsuccessful login.
+     *
+     * @throws ILSException
+     */
+    public function patronLogin($username, $password)
+    {
+        if ($username == '' || $password == '') {
+            throw new ILSException('Invalid Login, Please try again.');
+        }
+
+        $session = $this->getSession();
+
+        // if we already have a session with access_token and patron id, try to get
+        // patron info with session data
+        if (isset($session->expires) && $session->expires > time()) {
+            try {
+                return $this->enrichUserDetails(
+                    $this->paiaGetUserDetails($session->patron),
+                    $password
+                );
+            } catch (ILSException $e) {
+                $this->debug('Session expired, login again', ['info' => 'info']);
+            }
+        }
+        try {
+            if ($this->paiaLogin($username, $password)) {
+                return $this->enrichUserDetails(
+                    $this->paiaGetUserDetails($session->patron),
+                    $password
+                );
+            }
+        } catch (ILSException $e) {
+            throw new ILSException($e->getMessage());
+        }
+    }
+
+    /**
+     * PAIA helper function to map session data to return value of patronLogin()
+     *
+     * @param array  $details  Patron details returned by patronLogin
+     * @param string $password Patron cataloge password
+     *
+     * @return mixed
+     */
+    protected function enrichUserDetails($details, $password)
+    {
+        $session = $this->getSession();
+
+        $details['cat_username'] = $session->patron;
+        $details['cat_password'] = $password;
+        return $details;
+    }
+
+    /**
+     * Returns an array with PAIA confirmations based on the given holdDetails which
+     * will be used for a request.
+     * Currently two condition types are supported:
+     *  - http://purl.org/ontology/paia#StorageCondition to select a document
+     *    location -- mapped to pickUpLocation
+     *  - http://purl.org/ontology/paia#FeeCondition to confirm or select a document
+     *    service causing a fee -- not mapped yet
+     *
+     * @param array $holdDetails An array of item and patron data
+     *
+     * @return array
+     */
+    protected function getConfirmations($holdDetails)
+    {
+        $confirmations = [];
+        if (isset($holdDetails['pickUpLocation'])) {
+            $confirmations['http://purl.org/ontology/paia#StorageCondition']
+                = [$holdDetails['pickUpLocation']];
+        }
+        return $confirmations;
+    }
+
+    /**
+     * Place Hold
+     *
+     * Attempts to place a hold or recall on a particular item and returns
+     * an array with result details
+     *
+     * Make a request on a specific record
+     *
+     * @param array $holdDetails An array of item and patron data
+     *
+     * @return mixed An array of data on the request including
+     * whether or not it was successful and a system message (if available)
+     */
+    public function placeHold($holdDetails)
+    {
+        $item = $holdDetails['item_id'];
+        $patron = $holdDetails['patron'];
+
+        $doc = [];
+        $doc['item'] = stripslashes($item);
+        if ($confirm = $this->getConfirmations($holdDetails)) {
+            $doc["confirm"] = $confirm;
+        }
+        $post_data['doc'][] = $doc;
+
+        try {
+            $array_response = $this->paiaPostAsArray(
+                'core/' . $patron['cat_username'] . '/request', $post_data
+            );
+        } catch (ILSException $e) {
+            $this->debug($e->getMessage());
+            return [
+                'success' => false,
+                'sysMessage' => $e->getMessage(),
+            ];
+        }
+
+        $details = [];
+        if (isset($array_response['error'])) {
+            $details = [
+                'success' => false,
+                'sysMessage' => $array_response['error_description']
+            ];
+        } else {
+            $elements = $array_response['doc'];
+            foreach ($elements as $element) {
+                if (isset($element['error'])) {
+                    $details = [
+                        'success' => false,
+                        'sysMessage' => $element['error']
+                    ];
+                } else {
+                    $details = [
+                        'success' => true,
+                        'sysMessage' => 'Successfully requested'
+                    ];
+                    // if caching is enabled for DAIA remove the cached data for the
+                    // current item otherwise the changed status will not be shown
+                    // before the cache expires
+                    if ($this->daiaCacheEnabled) {
+                        $this->removeCachedData($holdDetails['doc_id']);
+                    }
+                }
+            }
+        }
+        return $details;
+    }
+
+    /**
+     * Place a Storage Retrieval Request
+     *
+     * Attempts to place a request on a particular item and returns
+     * an array with result details.
+     *
+     * @param array $details An array of item and patron data
+     *
+     * @return mixed An array of data on the request including
+     * whether or not it was successful and a system message (if available)
+     */
+    public function placeStorageRetrievalRequest($details)
+    {
+        // Making a storage retrieval request is the same in PAIA as placing a Hold
+        return $this->placeHold($details);
+    }
+
+    /**
+     * This method renews a list of items for a specific patron.
+     *
+     * @param array $details - An associative array with two keys:
+     *      patron - array returned by patronLogin method
+     *      details - array of values returned by the getRenewDetails method
+     *                identifying which items to renew
+     *
+     * @return array - An associative array with two keys:
+     *     blocks - An array of strings specifying why a user is blocked from
+     *              renewing (false if no blocks)
+     *     details - Not set when blocks exist; otherwise, an array of
+     *               associative arrays (keyed by item ID) with each subarray
+     *               containing these keys:
+     *                  success – Boolean true or false
+     *                  new_date – string – A new due date
+     *                  new_time – string – A new due time
+     *                  item_id – The item id of the renewed item
+     *                  sysMessage – A system supplied renewal message (optional)
+     */
+    public function renewMyItems($details)
+    {
+        $it = $details['details'];
+        $items = [];
+        foreach ($it as $item) {
+            $items[] = ['item' => stripslashes($item)];
+        }
+        $patron = $details['patron'];
+        $post_data = ["doc" => $items];
+
+        try {
+            $array_response = $this->paiaPostAsArray(
+                'core/' . $patron['cat_username'] . '/renew', $post_data
+            );
+        } catch (ILSException $e) {
+            $this->debug($e->getMessage());
+            return [
+                'success' => false,
+                'sysMessage' => $e->getMessage(),
+            ];
+        }
+
+        $details = [];
+
+        if (isset($array_response['error'])) {
+            $details[] = [
+                'success' => false,
+                'sysMessage' => $array_response['error_description']
+            ];
+        } else {
+            $elements = $array_response['doc'];
+            foreach ($elements as $element) {
+                // VuFind can only assign the response to an id - if none is given
+                // (which is possible) simply skip this response element
+                if (isset($element['item'])) {
+                    if (isset($element['error'])) {
+                        $details[$element['item']] = [
+                            'success' => false,
+                            'sysMessage' => $element['error']
+                        ];
+                    } elseif ($element['status'] == '3') {
+                        $details[$element['item']] = [
+                            'success'  => true,
+                            'new_date' => isset($element['endtime'])
+                                ? $this->convertDatetime($element['endtime']) : '',
+                            'item_id'  => 0,
+                            'sysMessage' => 'Successfully renewed'
+                        ];
+                    } else {
+                        $details[$element['item']] = [
+                            'success'  => false,
+                            'item_id'  => 0,
+                            'new_date' => isset($element['endtime'])
+                                ? $this->convertDatetime($element['endtime']) : '',
+                            'sysMessage' => 'Request rejected'
+                        ];
+                    }
+                }
+
+                // DAIA cache cannot be cleared for particular item as PAIA only
+                // operates with specific item URIs and the DAIA cache is setup
+                // by doc URIs (containing items with URIs)
+            }
+
+            // If caching is enabled for PAIA clear the cache as at least for one
+            // item renew was successfull and therefore the status changed. Otherwise
+            // the changed status will not be shown before the cache expires.
+            if ($this->paiaCacheEnabled) {
+                $this->removeCachedData($patron['cat_username']);
+            }
+        }
+        $returnArray = ['blocks' => false, 'details' => $details];
+        return $returnArray;
+    }
+
+    /*
+     * PAIA functions
+     */
+
+    /**
+     * PAIA support method to return strings for PAIA service status values
+     *
+     * @param string $status PAIA service status
+     *
+     * @return string Describing PAIA service status
+     */
+    protected function paiaStatusString($status)
+    {
+        return isset(self::$statusStrings[$status])
+            ? self::$statusStrings[$status] : '';
+    }
+
+    /**
+     * PAIA support method for PAIA core method 'items' returning only those
+     * documents containing the given service status.
+     *
+     * @param array $patron Array with patron information
+     * @param array $filter Array of properties identifying the wanted items
+     *
+     * @return array|mixed Array of documents containing the given filter properties
+     */
+    protected function paiaGetItems($patron, $filter = [])
+    {
+        // check for existing data in cache
+        if ($this->paiaCacheEnabled) {
+            $itemsResponse = $this->getCachedData($patron['cat_username']);
+        }
+
+        if (!isset($itemsResponse) || $itemsResponse == null) {
+            $itemsResponse = $this->paiaGetAsArray(
+                'core/' . $patron['cat_username'] . '/items'
+            );
+            if ($this->paiaCacheEnabled) {
+                $this->putCachedData($patron['cat_username'], $itemsResponse);
+            }
+        }
+
+        if (isset($itemsResponse['doc'])) {
+            if (count($filter)) {
+                $filteredItems = [];
+                foreach ($itemsResponse['doc'] as $doc) {
+                    $filterCounter = 0;
+                    foreach ($filter as $filterKey => $filterValue) {
+                        if (isset($doc[$filterKey])
+                            && in_array($doc[$filterKey], (array)$filterValue)
+                        ) {
+                            $filterCounter++;
+                        }
+                    }
+                    if ($filterCounter == count($filter)) {
+                        $filteredItems[] = $doc;
+                    }
+                }
+                return $filteredItems;
+            } else {
+                return $itemsResponse;
+            }
+        } else {
+            $this->debug(
+                "No documents found in PAIA response. Returning empty array."
+            );
+        }
+        return [];
+    }
+
+    /**
+     * PAIA support method to retrieve needed ItemId in case PAIA-response does not
+     * contain it
+     *
+     * @param string $id itemId
+     *
+     * @return string $id
+     */
+    protected function getAlternativeItemId($id)
+    {
+        return $id;
+    }
+
+    /**
+     * PAIA support function to implement ILS specific parsing of user_details
+     *
+     * @param string $patron        User id
+     * @param array  $user_response Array with PAIA response data
+     *
+     * @return array
+     */
+    protected function paiaParseUserDetails($patron, $user_response)
+    {
+        $username = trim($user_response['name']);
+        if (count(explode(',', $username)) == 2) {
+            $nameArr = explode(',', $username);
+            $firstname = $nameArr[1];
+            $lastname = $nameArr[0];
+        } else {
+            $nameArr = explode(' ', $username);
+            $firstname = $nameArr[0];
+            $lastname = '';
+            array_shift($nameArr);
+            foreach ($nameArr as $value) {
+                $lastname .= ' ' . $value;
+            }
+            $lastname = trim($lastname);
+        }
+
+        // TODO: implement parsing of user details according to types set
+        // (cf. https://github.com/gbv/paia/issues/29)
+
+        $user = [];
+        $user['id']        = $patron;
+        $user['firstname'] = $firstname;
+        $user['lastname']  = $lastname;
+        $user['email']     = (isset($user_response['email'])
+            ? $user_response['email'] : '');
+        $user['major']     = null;
+        $user['college']   = null;
+        // add other information from PAIA - we don't want anything to get lost
+        // while parsing
+        foreach ($user_response as $key => $value) {
+            if (!isset($user[$key])) {
+                $user[$key] = $value;
+            }
+        }
+        return $user;
+    }
+
+    /**
+     * PAIA helper function to allow customization of mapping from PAIA response to
+     * VuFind ILS-method return values.
+     *
+     * @param array  $items   Array of PAIA items to be mapped
+     * @param string $mapping String identifying a custom mapping-method
+     *
+     * @return array
+     */
+    protected function mapPaiaItems($items, $mapping)
+    {
+        if (is_callable([$this, $mapping])) {
+            return $this->$mapping($items);
+        }
+
+        $this->debug('Could not call method: ' . $mapping . '() .');
+        return [];
+    }
+
+    /**
+     * This PAIA helper function allows custom overrides for mapping of PAIA response
+     * to getMyHolds data structure.
+     *
+     * @param array $items Array of PAIA items to be mapped.
+     *
+     * @return array
+     */
+    protected function myHoldsMapping($items)
+    {
+        $results = [];
+
+        foreach ($items as $doc) {
+            $result = [];
+
+            // item (0..1) URI of a particular copy
+            $result['item_id'] = (isset($doc['item']) ? $doc['item'] : '');
+
+            $result['cancel_details']
+                = (isset($doc['cancancel']) && $doc['cancancel'])
+                ? $result['item_id'] : '';
+
+            // edition (0..1) URI of a the document (no particular copy)
+            // hook for retrieving alternative ItemId in case PAIA does not
+            // the needed id
+            $result['id'] = (isset($doc['edition'])
+                ? $this->getAlternativeItemId($doc['edition']) : '');
+
+            $result['type'] = $this->paiaStatusString($doc['status']);
+
+            // storage (0..1) textual description of location of the document
+            $result['location'] = (isset($doc['storage']) ? $doc['storage'] : null);
+
+            // queue (0..1) number of waiting requests for the document or item
+            $result['position'] =  (isset($doc['queue']) ? $doc['queue'] : null);
+
+            // only true if status == 4
+            $result['available'] = false;
+
+            // about (0..1) textual description of the document
+            $result['title'] = (isset($doc['about']) ? $doc['about'] : null);
+
+            // PAIA custom field
+            // label (0..1) call number, shelf mark or similar item label
+            $result['callnumber'] = $this->getCallNumber($doc);
+
+            /*
+             * meaning of starttime and endtime depends on status:
+             *
+             * status | starttime
+             *        | endtime
+             * -------+--------------------------------
+             * 0      | -
+             *        | -
+             * 1      | when the document was reserved
+             *        | when the reserved document is expected to be available
+             * 2      | when the document was ordered
+             *        | when the ordered document is expected to be available
+             * 3      | when the document was lend
+             *        | when the loan period ends or ended (due)
+             * 4      | when the document is provided
+             *        | when the provision will expire
+             * 5      | when the request was rejected
+             *        | -
+             */
+
+            $result['create'] = (isset($doc['starttime'])
+                ? $this->convertDatetime($doc['starttime']) : '');
+
+            if ($doc['status'] == '4') {
+                $result['expire'] = (isset($doc['endtime'])
+                    ? $this->convertDatetime($doc['endtime']) : '');
+            } else {
+                $result['duedate'] = (isset($doc['endtime'])
+                    ? $this->convertDatetime($doc['endtime']) : '');
+            }
+
+            // status: provided (the document is ready to be used by the patron)
+            $result['available'] = $doc['status'] == 4 ? true : false;
+
+            // Optional VuFind fields
+            /*
+            $result['reqnum'] = null;
+            $result['volume'] =  null;
+            $result['publication_year'] = null;
+            $result['isbn'] = null;
+            $result['issn'] = null;
+            $result['oclc'] = null;
+            $result['upc'] = null;
+            */
+
+            $results[] = $result;
+
+        }
+        return $results;
+    }
+
+    /**
+     * This PAIA helper function allows custom overrides for mapping of PAIA response
+     * to getMyStorageRetrievalRequests data structure.
+     *
+     * @param array $items Array of PAIA items to be mapped.
+     *
+     * @return array
+     */
+    protected function myStorageRetrievalRequestsMapping($items)
+    {
+        $results = [];
+
+        foreach ($items as $doc) {
+            $result = [];
+
+            // item (0..1) URI of a particular copy
+            $result['item_id'] = (isset($doc['item']) ? $doc['item'] : '');
+
+            $result['cancel_details']
+                = (isset($doc['cancancel']) && $doc['cancancel'])
+                ? $result['item_id'] : '';
+
+            // edition (0..1) URI of a the document (no particular copy)
+            // hook for retrieving alternative ItemId in case PAIA does not
+            // the needed id
+            $result['id'] = (isset($doc['edition'])
+                ? $this->getAlternativeItemId($doc['edition']) : '');
+
+            $result['type'] = $this->paiaStatusString($doc['status']);
+
+            // storage (0..1) textual description of location of the document
+            $result['location'] = (isset($doc['storage']) ? $doc['storage'] : null);
+
+            // queue (0..1) number of waiting requests for the document or item
+            $result['position'] =  (isset($doc['queue']) ? $doc['queue'] : null);
+
+            // only true if status == 4
+            $result['available'] = false;
+
+            // about (0..1) textual description of the document
+            $result['title'] = (isset($doc['about']) ? $doc['about'] : null);
+
+            // PAIA custom field
+            // label (0..1) call number, shelf mark or similar item label
+            $result['callnumber'] = $this->getCallNumber($doc);
+
+            $result['create'] = (isset($doc['starttime'])
+                ? $this->convertDatetime($doc['starttime']) : '');
+
+            // Optional VuFind fields
+            /*
+            $result['reqnum'] = null;
+            $result['volume'] =  null;
+            $result['publication_year'] = null;
+            $result['isbn'] = null;
+            $result['issn'] = null;
+            $result['oclc'] = null;
+            $result['upc'] = null;
+            */
+
+            $results[] = $result;
+
+        }
+        return $results;
+    }
+
+    /**
+     * This PAIA helper function allows custom overrides for mapping of PAIA response
+     * to getMyTransactions data structure.
+     *
+     * @param array $items Array of PAIA items to be mapped.
+     *
+     * @return array
+     */
+    protected function myTransactionsMapping($items)
+    {
+        $results = [];
+
+        foreach ($items as $doc) {
+            $result = [];
+            // canrenew (0..1) whether a document can be renewed (bool)
+            $result['renewable'] = (isset($doc['canrenew'])
+                ? $doc['canrenew'] : false);
+
+            // item (0..1) URI of a particular copy
+            $result['item_id'] = (isset($doc['item']) ? $doc['item'] : '');
+
+            $result['renew_details']
+                = (isset($doc['canrenew']) && $doc['canrenew'])
+                ? $result['item_id'] : '';
+
+            // edition (0..1)  URI of a the document (no particular copy)
+            // hook for retrieving alternative ItemId in case PAIA does not
+            // the needed id
+            $result['id'] = (isset($doc['edition'])
+                ? $this->getAlternativeItemId($doc['edition']) : '');
+
+            // requested (0..1) URI that was originally requested
+
+            // about (0..1) textual description of the document
+            $result['title'] = (isset($doc['about']) ? $doc['about'] : null);
+
+            // queue (0..1) number of waiting requests for the document or item
+            $result['request'] = (isset($doc['queue']) ? $doc['queue'] : null);
+
+            // renewals (0..1) number of times the document has been renewed
+            $result['renew'] = (isset($doc['renewals']) ? $doc['renewals'] : null);
+
+            // reminder (0..1) number of times the patron has been reminded
+            $result['reminder'] = (
+                isset($doc['reminder']) ? $doc['reminder'] : null
+            );
+
+            // custom PAIA field
+            // starttime (0..1) date and time when the status began
+            $result['startTime'] = (isset($doc['starttime'])
+                ? $this->convertDatetime($doc['starttime']) : '');
+
+            // endtime (0..1) date and time when the status will expire
+            $result['dueTime'] = (isset($doc['endtime'])
+                ? $this->convertDatetime($doc['endtime']) : '');
+
+            // duedate (0..1) date when the current status will expire (deprecated)
+            $result['duedate'] = (isset($doc['duedate'])
+                ? $this->convertDate($doc['duedate']) : '');
+
+            // cancancel (0..1) whether an ordered or provided document can be
+            // canceled
+
+            // error (0..1) error message, for instance if a request was rejected
+            $result['message'] = (isset($doc['error']) ? $doc['error'] : '');
+
+            // storage (0..1) textual description of location of the document
+            $result['borrowingLocation'] = (isset($doc['storage'])
+                ? $doc['storage'] : '');
+
+            // storageid (0..1) location URI
+
+            // PAIA custom field
+            // label (0..1) call number, shelf mark or similar item label
+            $result['callnumber'] = $this->getCallNumber($doc);
+
+            // Optional VuFind fields
+            /*
+            $result['barcode'] = null;
+            $result['dueStatus'] = null;
+            $result['renewLimit'] = "1";
+            $result['volume'] = null;
+            $result['publication_year'] = null;
+            $result['isbn'] = null;
+            $result['issn'] = null;
+            $result['oclc'] = null;
+            $result['upc'] = null;
+            $result['institution_name'] = null;
+            */
+
+            $results[] = $result;
+        }
+
+        return $results;
+    }
+
+    /**
+     * Post something to a foreign host
+     *
+     * @param string $file         POST target URL
+     * @param string $data_to_send POST data
+     * @param string $access_token PAIA access token for current session
+     *
+     * @return string POST response
+     * @throws ILSException
+     */
+    protected function paiaPostRequest($file, $data_to_send, $access_token = null)
+    {
+        // json-encoding
+        $postData = stripslashes(json_encode($data_to_send));
+
+        $http_headers = [];
+        if (isset($access_token)) {
+            $http_headers['Authorization'] = 'Bearer ' . $access_token;
+        }
+
+        try {
+            $result = $this->httpService->post(
+                $this->paiaURL . $file,
+                $postData,
+                'application/json; charset=UTF-8',
+                null,
+                $http_headers
+            );
+        } catch (\Exception $e) {
+            throw new ILSException($e->getMessage());
+        }
+
+        if (!$result->isSuccess()) {
+            // log error for debugging
+            $this->debug(
+                'HTTP status ' . $result->getStatusCode() .
+                ' received'
+            );
+        }
+        // return any result as error-handling is done elsewhere
+        return ($result->getBody());
+    }
+
+    /**
+     * GET data from foreign host
+     *
+     * @param string $file         GET target URL
+     * @param string $access_token PAIA access token for current session
+     *
+     * @return bool|string
+     * @throws ILSException
+     */
+    protected function paiaGetRequest($file, $access_token)
+    {
+        $http_headers = [
+            'Authorization' => 'Bearer ' . $access_token,
+            'Content-type' => 'application/json; charset=UTF-8',
+        ];
+
+        try {
+            $result = $this->httpService->get(
+                $this->paiaURL . $file,
+                [], null, $http_headers
+            );
+        } catch (\Exception $e) {
+            throw new ILSException($e->getMessage());
+        }
+
+        if (!$result->isSuccess()) {
+            // log error for debugging
+            $this->debug(
+                'HTTP status ' . $result->getStatusCode() .
+                ' received'
+            );
+        }
+        // return any result as error-handling is done elsewhere
+        return ($result->getBody());
+    }
+
+    /**
+     * Helper function for PAIA to uniformely parse JSON
+     *
+     * @param string $file JSON data
+     *
+     * @return mixed
+     * @throws ILSException
+     */
+    protected function paiaParseJsonAsArray($file)
+    {
+        $responseArray = json_decode($file, true);
+
+        if (isset($responseArray['error'])) {
+            throw new ILSException(
+                $responseArray['error'],
+                $responseArray['code']
+            );
+        }
+
+        return $responseArray;
+    }
+
+    /**
+     * Retrieve file at given URL and return it as json_decoded array
+     *
+     * @param string $file GET target URL
+     *
+     * @return array|mixed
+     * @throws ILSException
+     */
+    protected function paiaGetAsArray($file)
+    {
+        $responseJson = $this->paiaGetRequest(
+            $file,
+            $this->getSession()->access_token
+        );
+
+        try {
+            $responseArray = $this->paiaParseJsonAsArray($responseJson);
+        } catch (ILSException $e) {
+            $this->debug($e->getCode() . ':' . $e->getMessage());
+            return [];
+        }
+
+        return $responseArray;
+    }
+
+    /**
+     * Post something at given URL and return it as json_decoded array
+     *
+     * @param string $file POST target URL
+     * @param array  $data POST data
+     *
+     * @return array|mixed
+     * @throws ILSException
+     */
+    protected function paiaPostAsArray($file, $data)
+    {
+        $responseJson = $this->paiaPostRequest(
+            $file,
+            $data,
+            $this->getSession()->access_token
+        );
+
+        try {
+            $responseArray = $this->paiaParseJsonAsArray($responseJson);
+        } catch (ILSException $e) {
+            $this->debug($e->getCode() . ':' . $e->getMessage());
+            /* TODO: do not return empty array, this causes eventually confusion */
+            return [];
+        }
+
+        return $responseArray;
+    }
+
+    /**
+     * PAIA authentication function
+     *
+     * @param string $username Username
+     * @param string $password Password
+     *
+     * @return mixed Associative array of patron info on successful login,
+     * null on unsuccessful login, PEAR_Error on error.
+     * @throws ILSException
+     */
+    protected function paiaLogin($username, $password)
+    {
+        // perform full PAIA auth and get patron info
+        $post_data = [
+            "username"   => $username,
+            "password"   => $password,
+            "grant_type" => "password",
+            "scope"      => "read_patron read_fees read_items write_items " .
+                "change_password"
+        ];
+        $responseJson = $this->paiaPostRequest('auth/login', $post_data);
+
+        try {
+            $responseArray = $this->paiaParseJsonAsArray($responseJson);
+        } catch (ILSException $e) {
+            if ($e->getMessage() === 'access_denied') {
+                return false;
+            }
+            throw new ILSException(
+                $e->getCode() . ':' . $e->getMessage()
+            );
+        }
+
+        if (!isset($responseArray['access_token'])) {
+            throw new ILSException(
+                'Unknown error! Access denied.'
+            );
+        } elseif (!isset($responseArray['patron'])) {
+            throw new ILSException(
+                'Login credentials accepted, but got no patron ID?!?'
+            );
+        } else {
+            // at least access_token and patron got returned which is sufficient for
+            // us, now save all to session
+            $session = $this->getSession();
+
+            $session->patron
+                = isset($responseArray['patron'])
+                ? $responseArray['patron'] : null;
+            $session->access_token
+                = isset($responseArray['access_token'])
+                ? $responseArray['access_token'] : null;
+            $session->scope
+                = isset($responseArray['scope'])
+                ? explode(' ', $responseArray['scope']) : null;
+            $session->expires
+                = isset($responseArray['expires_in'])
+                ? (time() + ($responseArray['expires_in'])) : null;
+
+            return true;
+        }
+    }
+
+    /**
+     * Support method for paiaLogin() -- load user details into session and return
+     * array of basic user data.
+     *
+     * @param array $patron patron ID
+     *
+     * @return array
+     * @throws ILSException
+     */
+    protected function paiaGetUserDetails($patron)
+    {
+        $responseJson = $this->paiaGetRequest(
+            'core/' . $patron, $this->getSession()->access_token
+        );
+
+        try {
+            $responseArray = $this->paiaParseJsonAsArray($responseJson);
+        } catch (ILSException $e) {
+            throw new ILSException(
+                $e->getMessage(), $e->getCode()
+            );
+        }
+        return $this->paiaParseUserDetails($patron, $responseArray);
+    }
+
+    /**
+     * Check if storage retrieval request available
+     *
+     * This is responsible for determining if an item is requestable
+     *
+     * @param string $id     The Bib ID
+     * @param array  $data   An Array of item data
+     * @param patron $patron An array of patron data
+     *
+     * @return bool True if request is valid, false if not
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function checkStorageRetrievalRequestIsValid($id, $data, $patron)
+    {
+        return $this->checkRequestIsValid($id, $data, $patron);
+    }
+
+    /**
+     * Check if hold or recall available
+     *
+     * This is responsible for determining if an item is requestable
+     *
+     * @param string $id     The Bib ID
+     * @param array  $data   An Array of item data
+     * @param patron $patron An array of patron data
+     *
+     * @return bool True if request is valid, false if not
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function checkRequestIsValid($id, $data, $patron)
+    {
+        // TODO: make this more configurable
+        if (isset($patron['status']) && $patron['status']  == 0
+            && isset($patron['expires']) && $patron['expires'] > date('Y-m-d')
+            && in_array('write_items', $this->getScope())
+        ) {
+            return true;
+        }
+        return false;
+    }
+}
diff --git a/module/VuFind/tests/fixtures/paia/response/changePassword.json b/module/VuFind/tests/fixtures/paia/response/changePassword.json
new file mode 100644
index 00000000000..3a1981450d9
--- /dev/null
+++ b/module/VuFind/tests/fixtures/paia/response/changePassword.json
@@ -0,0 +1,15 @@
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=UTF-8
+Date: Mon, 20 Jun 2016 10:20:48 GMT
+Server: vzg-paia/2.2-RC3 (Wed Jun 01 17:38:24 CEST 2016)
+Cache-Control: no-cache, no-store
+Access-Control-Allow-Origin: *
+Access-Control-Expose-Headers: X-OAuth-Scopes, X-Accepted-OAuth-Scopes
+Access-Control-Allow-Headers: Content-Type, Authorization
+Pragma: no-cache
+X-OAuth-Scopes: change_password read_items read_fees read_patron
+X-Accepted-OAuth-Scopes: change_password
+
+{
+  "patron": "08301001001"
+}
diff --git a/module/VuFind/tests/fixtures/paia/response/fees.json b/module/VuFind/tests/fixtures/paia/response/fees.json
new file mode 100644
index 00000000000..010e18a05fd
--- /dev/null
+++ b/module/VuFind/tests/fixtures/paia/response/fees.json
@@ -0,0 +1,51 @@
+HTTP/1.1 200 OK
+Server: vzg-paia/2.2-RC3 (Wed Jun 01 17:38:24 CEST 2016)
+Date: Mon, 20 Jun 2016 11:30:05 GMT
+Cache-Control: no-cache
+Access-Control-Allow-Origin: *
+Access-Control-Expose-Headers: X-OAuth-Scopes, X-Accepted-OAuth-Scopes
+Access-Control-Allow-Headers: Content-Type, Authorization
+Pragma: no-cache
+X-OAuth-Scopes: read_availability change_password write_items read_items read_fees read_patron
+X-Accepted-OAuth-Scopes: read_fees
+Content-Type: application/json; charset=UTF-8
+
+{
+  "amount": "7.40 EUR",
+  "fee": [{
+    "feetypeid": "de-830:fee-type:2",
+    "amount": "1.60 EUR",
+    "date": "2016-06-07T11:19:11+02:00",
+    "feetype": "Vormerkgebuehr",
+    "about": "Open source licensing : software freedom and intellectual property law ; [open source licensees are free to: use open source software for any purpose, make and distribute copies, create and distribute derivative works, access and use the source code, com / Rosen, Lawrence (c 2005)",
+    "item": "http://uri.gbv.de/document/opac-de-830:bar:830$28295402"
+  }, {
+    "feetypeid": "de-830:fee-type:2",
+    "amount": "0.80 EUR",
+    "date": "2016-05-23T15:31:34+02:00",
+    "feetype": "Vormerkgebuehr",
+    "about": "Zend framework in action / Allen, Rob (2009)",
+    "item": "http://uri.gbv.de/document/opac-de-830:bar:830$28323471"
+  }, {
+    "feetypeid": "de-830:fee-type:3",
+    "amount": "3.00 EUR",
+    "date": "2016-05-24T02:31:30+02:00",
+    "feetype": "Säumnisgebühr",
+    "about": "Unsere historischen Gärten / Lutze, Margot (1986)",
+    "item": "http://uri.gbv.de/document/opac-de-830:bar:830$24476416"
+  }, {
+    "feetypeid": "de-830:fee-type:3",
+    "amount": "1.00 EUR",
+    "date": "2016-06-17T02:31:48+02:00",
+    "feetype": "Säumnisgebühr",
+    "about": "Triumphe des Backsteins = Triumphs of brick / (1992)",
+    "item": "http://uri.gbv.de/document/opac-de-830:bar:830$33204941"
+  }, {
+    "feetypeid": "de-830:fee-type:3",
+    "amount": "1.00 EUR",
+    "date": "2016-05-24T02:31:30+02:00",
+    "feetype": "Säumnisgebühr",
+    "about": "Lehrbuch der Botanik / Strasburger, Eduard (2008)",
+    "item": "http://uri.gbv.de/document/opac-de-830:bar:830$26461872"
+  }]
+}
diff --git a/module/VuFind/tests/fixtures/paia/response/items.json b/module/VuFind/tests/fixtures/paia/response/items.json
new file mode 100644
index 00000000000..15df98c4ee2
--- /dev/null
+++ b/module/VuFind/tests/fixtures/paia/response/items.json
@@ -0,0 +1,87 @@
+HTTP/1.1 200 OK
+Server: vzg-paia/2.2-RC3 (Wed Jun 01 17:38:24 CEST 2016)
+Date: Mon, 20 Jun 2016 10:38:21 GMT
+Cache-Control: no-cache
+Access-Control-Allow-Origin: *
+Access-Control-Expose-Headers: X-OAuth-Scopes, X-Accepted-OAuth-Scopes
+Access-Control-Allow-Headers: Content-Type, Authorization
+Pragma: no-cache
+X-OAuth-Scopes: change_password read_items read_fees read_patron
+X-Accepted-OAuth-Scopes: read_patron
+Content-Type: application/json; charset=UTF-8
+
+{
+  "doc": [{
+    "queue": 0,
+    "canrenew": false,
+    "cancancel": false,
+    "storageid": "de-830:desk:23",
+    "label": "34:3409-6983",
+    "reminder": 0,
+    "edition": "http://uri.gbv.de/document/opac-de-830:ppn:040445623",
+    "status": 4,
+    "starttime": "2016-06-17",
+    "storage": "Test-Theke",
+    "about": "Praktikum über Entwurf und Manipulation von Datenbanken : SQL/DS (IBM), UDS (Siemens) und MEMODAX / Vossen, Gottfried (1986)",
+    "renewals": 0,
+    "item": "http://uri.gbv.de/document/opac-de-830:bar:830$34096983"
+  }, {
+    "queue": 0,
+    "canrenew": false,
+    "cancancel": false,
+    "storageid": "de-830:desk:1",
+    "label": "24:2426-0127",
+    "reminder": 0,
+    "edition": "http://uri.gbv.de/document/opac-de-830:ppn:020966334",
+    "endtime": "2016-05-23",
+    "status": 2,
+    "starttime": "2016-04-25T08:50:41+02:00",
+    "storage": "Ausleihe",
+    "about": "Gold / Kettell, Brian (1982)",
+    "renewals": 0,
+    "item": "http://uri.gbv.de/document/opac-de-830:bar:830$24260127"
+  },{
+    "queue": 0,
+    "canrenew": false,
+    "cancancel": false,
+    "storageid": "de-830:desk:1",
+    "label": "28:2834-2436",
+    "reminder": 1,
+    "edition": "http://uri.gbv.de/document/opac-de-830:ppn:58891861X",
+    "endtime": "2016-06-15",
+    "status": 3,
+    "starttime": "2013-11-15",
+    "storage": "Ausleihe",
+    "about": "Theoretische Informatik : mit 22 Tabellen und 78 Aufgaben / Hoffmann, Dirk W. (2009)",
+    "renewals": 12,
+    "item": "http://uri.gbv.de/document/opac-de-830:bar:830$28342436"
+  }, {
+    "queue": 0,
+    "canrenew": false,
+    "cancancel": false,
+    "storageid": "de-830:desk:612",
+    "label": "22:2227-8001",
+    "reminder": 0,
+    "edition": "http://uri.gbv.de/document/opac-de-830:ppn:659228084",
+    "endtime": "2016-07-14",
+    "status": 3,
+    "starttime": "2011-12-22",
+    "storage": "Ausleihe",
+    "about": "Linked Open Library Data : bibliographische Daten und ihre Zugänglichkeit im Web der Daten ; Innovationspreis 2011 / Fürste, Fabian M. (2011)",
+    "renewals": 9,
+    "item": "http://uri.gbv.de/document/opac-de-830:bar:830$22278001"
+  }, {
+    "queue": 0,
+    "canrenew": false,
+    "cancancel": true,
+    "storageid": "de-830:desk:613",
+    "label": "28:2829-5402",
+    "edition": "http://uri.gbv.de/document/opac-de-830:ppn:391260316",
+    "endtime": "2016-06-15",
+    "status": 1,
+    "starttime": "2016-06-15T13:22:27+02:00",
+    "storage": "Ausleihe",
+    "about": "Open source licensing : software freedom and intellectual property law ; [open source licensees are free to: use open source software for any purpose, make and distribute copies, create and distribute derivative works, access and use the source code, com / Rosen, Lawrence (c 2005)",
+    "item": "http://uri.gbv.de/document/opac-de-830:bar:830$28295402"
+  } ]
+}
diff --git a/module/VuFind/tests/fixtures/paia/response/login.json b/module/VuFind/tests/fixtures/paia/response/login.json
new file mode 100644
index 00000000000..ac3fc2676ee
--- /dev/null
+++ b/module/VuFind/tests/fixtures/paia/response/login.json
@@ -0,0 +1,18 @@
+HTTP/1.1 200 OK
+Server: vzg-paia/2.2-RC3 (Wed Jun 01 17:38:24 CEST 2016)
+Date: Mon, 20 Jun 2016 13:35:01 GMT
+Cache-Control: no-cache, no-store
+Access-Control-Allow-Origin: *
+Access-Control-Expose-Headers: X-OAuth-Scopes, X-Accepted-OAuth-Scopes
+Access-Control-Allow-Headers: Content-Type, Authorization
+Pragma: no-cache
+Content-Type: application/json; charset=UTF-8
+
+{
+  "access_token": "AAASECRETBBB",
+  "expires_in": 3600,
+  "scope": "read_availability change_password write_items read_items read_fees read_patron",
+  "token_type": "Bearer",
+  "patron": "08301001001"
+}
+
diff --git a/module/VuFind/tests/fixtures/paia/response/login_bad.json b/module/VuFind/tests/fixtures/paia/response/login_bad.json
new file mode 100644
index 00000000000..e5379d8795c
--- /dev/null
+++ b/module/VuFind/tests/fixtures/paia/response/login_bad.json
@@ -0,0 +1,14 @@
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=UTF-8
+Server: vzg-paia/2.2-RC3 (Wed Jun 01 17:38:24 CEST 2016)
+Date: Mon, 20 Jun 2016 13:35:01 GMT
+Cache-Control: no-cache, no-store
+Access-Control-Allow-Origin: *
+Access-Control-Expose-Headers: X-OAuth-Scopes, X-Accepted-OAuth-Scopes
+Access-Control-Allow-Headers: Content-Type, Authorization
+Pragma: no-cache
+
+{
+  "error": "access_denied",
+  "error_description": "invalid patron or password"
+}
diff --git a/module/VuFind/tests/fixtures/paia/response/patron.json b/module/VuFind/tests/fixtures/paia/response/patron.json
new file mode 100644
index 00000000000..819320fdb9b
--- /dev/null
+++ b/module/VuFind/tests/fixtures/paia/response/patron.json
@@ -0,0 +1,22 @@
+HTTP/1.1 200 OK
+Server: vzg-paia/2.2-RC3 (Wed Jun 01 17:38:24 CEST 2016)
+Date: Mon, 20 Jun 2016 10:38:21 GMT
+Cache-Control: no-cache
+Access-Control-Allow-Origin: *
+Access-Control-Expose-Headers: X-OAuth-Scopes, X-Accepted-OAuth-Scopes
+Access-Control-Allow-Headers: Content-Type, Authorization
+Pragma: no-cache
+X-OAuth-Scopes: change_password read_items read_fees read_patron
+X-Accepted-OAuth-Scopes: read_patron
+Content-Type: application/json; charset=UTF-8
+
+{
+  "name": " Nobody Nothing",
+  "expires": "9999-12-31",
+  "email": "nobody@vufind.org",
+  "status": 0,
+  "address": "No Street at all 8, D-21073 Hamburg",
+  "type": ["de-830:user-type:20"]
+}
+
+
diff --git a/module/VuFind/tests/fixtures/paia/response/patron_expired.json b/module/VuFind/tests/fixtures/paia/response/patron_expired.json
new file mode 100644
index 00000000000..acf8c631672
--- /dev/null
+++ b/module/VuFind/tests/fixtures/paia/response/patron_expired.json
@@ -0,0 +1,22 @@
+HTTP/1.1 200 OK
+Server: vzg-paia/2.2-RC3 (Wed Jun 01 17:38:24 CEST 2016)
+Date: Mon, 20 Jun 2016 10:38:21 GMT
+Cache-Control: no-cache
+Access-Control-Allow-Origin: *
+Access-Control-Expose-Headers: X-OAuth-Scopes, X-Accepted-OAuth-Scopes
+Access-Control-Allow-Headers: Content-Type, Authorization
+Pragma: no-cache
+X-OAuth-Scopes: change_password read_items read_fees read_patron
+X-Accepted-OAuth-Scopes: read_patron
+Content-Type: application/json; charset=UTF-8
+
+{
+  "name": " Nobody Nothing",
+  "expires": "2015-12-31",
+  "email": "nobody@vufind.org",
+  "status": 0,
+  "address": "No Street at all 8, D-21073 Hamburg",
+  "type": ["de-830:user-type:20"]
+}
+
+
diff --git a/module/VuFind/tests/fixtures/paia/response/patron_locked.json b/module/VuFind/tests/fixtures/paia/response/patron_locked.json
new file mode 100644
index 00000000000..1392f029859
--- /dev/null
+++ b/module/VuFind/tests/fixtures/paia/response/patron_locked.json
@@ -0,0 +1,22 @@
+HTTP/1.1 200 OK
+Server: vzg-paia/2.2-RC3 (Wed Jun 01 17:38:24 CEST 2016)
+Date: Mon, 20 Jun 2016 10:38:21 GMT
+Cache-Control: no-cache
+Access-Control-Allow-Origin: *
+Access-Control-Expose-Headers: X-OAuth-Scopes, X-Accepted-OAuth-Scopes
+Access-Control-Allow-Headers: Content-Type, Authorization
+Pragma: no-cache
+X-OAuth-Scopes: change_password read_items read_fees read_patron
+X-Accepted-OAuth-Scopes: read_patron
+Content-Type: application/json; charset=UTF-8
+
+{
+  "name": " Nobody Nothing",
+  "expires": "9999-12-31",
+  "email": "nobody@vufind.org",
+  "status": 9,
+  "address": "No Street at all 8, D-21073 Hamburg",
+  "type": ["de-830:user-type:20"]
+}
+
+
diff --git a/module/VuFind/tests/fixtures/paia/response/renew_error.json b/module/VuFind/tests/fixtures/paia/response/renew_error.json
new file mode 100644
index 00000000000..e1d95e98308
--- /dev/null
+++ b/module/VuFind/tests/fixtures/paia/response/renew_error.json
@@ -0,0 +1,17 @@
+HTTP/1.1 200 OK
+Server: vzg-paia/2.2-RC3 (Wed Jun 01 17:38:24 CEST 2016)
+Date: Mon, 20 Jun 2016 11:21:15 GMT
+Cache-Control: no-cache
+Access-Control-Allow-Origin: *
+Access-Control-Expose-Headers: X-OAuth-Scopes, X-Accepted-OAuth-Scopes
+Access-Control-Allow-Headers: Content-Type, Authorization
+Pragma: no-cache
+X-OAuth-Scopes: change_password read_items read_fees read_patron
+X-Accepted-OAuth-Scopes: write_items
+Content-Type: application/json; charset=UTF-8
+
+{
+  "error": "insufficient_scope",
+  "code": 403,
+  "error_description": "The access token was accepted but it lacks permission for the request"
+}
\ No newline at end of file
diff --git a/module/VuFind/tests/fixtures/paia/response/renew_ok.json b/module/VuFind/tests/fixtures/paia/response/renew_ok.json
new file mode 100644
index 00000000000..be60b443e02
--- /dev/null
+++ b/module/VuFind/tests/fixtures/paia/response/renew_ok.json
@@ -0,0 +1,30 @@
+HTTP/1.1 200 OK
+Server: vzg-paia/2.2-RC3 (Wed Jun 01 17:38:24 CEST 2016)
+Date: Mon, 20 Jun 2016 11:23:46 GMT
+Cache-Control: no-cache
+Access-Control-Allow-Origin: *
+Access-Control-Expose-Headers: X-OAuth-Scopes, X-Accepted-OAuth-Scopes
+Access-Control-Allow-Headers: Content-Type, Authorization
+Pragma: no-cache
+X-OAuth-Scopes: read_availability change_password write_items read_items read_fees read_patron
+X-Accepted-OAuth-Scopes: write_items
+Content-Type: application/json; charset=UTF-8
+
+{
+  "doc": [{
+    "requested": "http://uri.gbv.de/document/opac-de-830:bar:830$22061137",
+    "queue": 0,
+    "cancancel": false,
+    "storageid": "de-830:desk:1",
+    "label": "22:2206-1137",
+    "reminder": 0,
+    "edition": "http://uri.gbv.de/document/opac-de-830:ppn:048430196",
+    "endtime": "2016-07-18",
+    "status": 3,
+    "starttime": "2016-06-07",
+    "storage": "Ausleihe",
+    "about": "Verteilte Datenbanken /  (1991)",
+    "renewals": 1,
+    "item": "http://uri.gbv.de/document/opac-de-830:bar:830$22061137"
+  }]
+}
diff --git a/module/VuFind/tests/fixtures/paia/response/storageretrieval.json b/module/VuFind/tests/fixtures/paia/response/storageretrieval.json
new file mode 100644
index 00000000000..31cfed23463
--- /dev/null
+++ b/module/VuFind/tests/fixtures/paia/response/storageretrieval.json
@@ -0,0 +1,29 @@
+HTTP/1.1 200 OK
+Server: vzg-paia/2.2-RC3 (Wed Jun 01 17:38:24 CEST 2016)
+Date: Mon, 20 Jun 2016 11:26:47 GMT
+Cache-Control: no-cache
+Access-Control-Allow-Origin: *
+Access-Control-Expose-Headers: X-OAuth-Scopes, X-Accepted-OAuth-Scopes
+Access-Control-Allow-Headers: Content-Type, Authorization
+Pragma: no-cache
+X-OAuth-Scopes: read_availability change_password write_items read_items read_fees read_patron
+X-Accepted-OAuth-Scopes: write_items
+Content-Type: application/json; charset=UTF-8
+
+{
+  "doc": [{
+    "queue": 0,
+    "cancancel": false,
+    "storageid": "de-830:desk:1",
+    "label": "24:2401-4292",
+    "reminder": 0,
+    "edition": "http://uri.gbv.de/document/opac-de-830:ppn:221142312",
+    "endtime": "2016-07-18",
+    "status": 2,
+    "starttime": "2016-06-20T13:26:46+02:00",
+    "storage": "Ausleihe",
+    "about": "Konzepte von Datenbanken / Kuhlen, Rainer (1979)",
+    "renewals": 0,
+    "item": "http://uri.gbv.de/document/opac-de-830:bar:830$24014292"
+  }]
+}
\ No newline at end of file
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/DAIATest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/DAIATest.php
index 78216e57709..4d83a8d70ec 100644
--- a/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/DAIATest.php
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/DAIATest.php
@@ -54,15 +54,26 @@ class DAIATest extends \VuFindTest\Unit\ILSDriverTestCase
                 'requests_placed' => '',
                 'id' => "027586081",
                 'item_id' => "http://uri.gbv.de/document/opac-de-000:epn:711134758",
-                'ilslink' => "http://opac.example-library.edu/DB=1/PPNSET?PPN=027586081",
+                'ilslink' => "http://opac.example-library.edu/loan/REQ?EPN=711134758",
                 'number' => 1,
                 'barcode' => "1",
                 'reserve' => "N",
                 'callnumber' => "ABC 12",
-                'location' => "Example Library for DAIA Tests - Abteilung III",
-                'locationhref' => false,
+                'location' => 'Example Library for DAIA Tests',
+                'locationid' => 'http://uri.gbv.de/organization/isil/DE-000',
+                'locationhref' => 'http://www.example-library.edu',
+                'storage' => 'Abteilung III',
+                'storageid' => '',
+                'storagehref' => '',
                 'item_notes' => [],
-                'services' => ['loan', 'presentation']
+                'services' => ['loan', 'presentation'],
+                'is_holdable' => false,
+                'addLink' => false,
+                'holdtype' => null,
+                'addStorageRetrievalRequestLink' => true,
+                'customData' => [],
+                'limitation_types' => [],
+                'doc_id' => 'http://uri.gbv.de/document/opac-de-000:ppn:027586081'
             ],
         1 =>
             [
@@ -77,10 +88,21 @@ class DAIATest extends \VuFindTest\Unit\ILSDriverTestCase
                 'barcode' => "1",
                 'reserve' => "N",
                 'callnumber' => "DEF 34",
-                'location' => "Example Library for DAIA Tests - Abteilung III",
-                'locationhref' => false,
+                'location' => 'Example Library for DAIA Tests',
+                'locationid' => 'http://uri.gbv.de/organization/isil/DE-000',
+                'locationhref' => 'http://www.example-library.edu',
+                'storage' => 'Abteilung III',
+                'storageid' => '',
+                'storagehref' => '',
                 'item_notes' => ['mit Zustimmung', 'nur Kopie'],
-                'services' => ['loan', 'presentation']
+                'services' => ['loan', 'presentation'],
+                'is_holdable' => false,
+                'addLink' => false,
+                'holdtype' => null,
+                'addStorageRetrievalRequestLink' => false,
+                'customData' => [],
+                'limitation_types' => [],
+                'doc_id' => 'http://uri.gbv.de/document/opac-de-000:ppn:027586081'
             ],
         2 =>
             [
@@ -95,10 +117,21 @@ class DAIATest extends \VuFindTest\Unit\ILSDriverTestCase
                 'barcode' => "1",
                 'reserve' => "N",
                 'callnumber' => "GHI 56",
-                'location' => "Example Library for DAIA Tests - Abteilung III",
-                'locationhref' => false,
+                'location' => 'Example Library for DAIA Tests',
+                'locationid' => 'http://uri.gbv.de/organization/isil/DE-000',
+                'locationhref' => 'http://www.example-library.edu',
+                'storage' => 'Abteilung III',
+                'storageid' => '',
+                'storagehref' => '',
                 'item_notes' => [],
-                'services' => []
+                'services' => [],
+                'is_holdable' => false,
+                'addLink' => false,
+                'holdtype' => null,
+                'addStorageRetrievalRequestLink' => false,
+                'customData' => [],
+                'limitation_types' => [],
+                'doc_id' => 'http://uri.gbv.de/document/opac-de-000:ppn:027586081'
             ],
     ];
 
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/PAIATest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/PAIATest.php
new file mode 100644
index 00000000000..1af911b12ca
--- /dev/null
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/PAIATest.php
@@ -0,0 +1,628 @@
+<?php
+/**
+ * ILS driver test
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2011.
+ *
+ * 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., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Oliver Goldschmidt <o.goldschmidt@tuhh.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+namespace VuFindTest\ILS\Driver;
+use VuFind\ILS\Driver\PAIA;
+
+use Zend\Http\Client\Adapter\Test as TestAdapter;
+use Zend\Http\Response as HttpResponse;
+
+use InvalidArgumentException;
+
+/**
+ * ILS driver test
+ *
+ * @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 Main Page
+ */
+class PAIATest extends \VuFindTest\Unit\ILSDriverTestCase
+{
+    protected $validConfig = [
+        'DAIA' =>
+            [
+                'baseUrl'            => 'http://daia.gbv.de/',
+            ],
+        'PAIA' =>
+            [
+                'baseUrl'            => 'http://paia.gbv.de/',
+            ]
+    ];
+
+    protected $patron = [
+        'id' => '08301001001',
+        'firstname' => 'Nobody',
+        'lastname' => 'Nothing',
+        'email' => 'nobody@vufind.org',
+        'major' => null,
+        'college' => null,
+        'name' => ' Nobody Nothing',
+        'expires' => '9999-12-31',
+        'status' => 0,
+        'address' => 'No street at all 8, D-21073 Hamburg',
+        'type' => [
+            0 => 'de-830:user-type:2'
+        ],
+        'cat_username' => '08301001001',
+        'cat_password' => 'NOPASSWORD'
+    ];
+
+    protected $patron_bad = [
+        'id' => '08301001011',
+        'firstname' => 'Invalid',
+        'lastname' => 'Nobody',
+        'email' => 'nobody_invalid@vufind.org',
+        'major' => null,
+        'college' => null,
+        'name' => ' Nobody Nothing',
+        'expires' => '9999-12-31',
+        'status' => 9,
+        'address' => 'No street at all 8, D-21073 Hamburg',
+        'type' => [
+            0 => 'de-830:user-type:2'
+        ],
+        'cat_username' => '08301001011',
+        'cat_password' => 'NOPASSWORD'
+    ];
+
+    protected $patron_expired = [
+        'id' => '08301001111',
+        'firstname' => 'Expired',
+        'lastname' => 'Nobody',
+        'email' => 'nobody_expired@vufind.org',
+        'major' => null,
+        'college' => null,
+        'name' => ' Nobody Nothing',
+        'expires' => '2015-12-31',
+        'status' => 0,
+        'address' => 'No street at all 8, D-21073 Hamburg',
+        'type' => [
+            0 => 'de-830:user-type:2'
+        ],
+        'cat_username' => '08301001111',
+        'cat_password' => 'NOPASSWORD'
+    ];
+
+    protected $feeTestResult = [
+        0 =>
+            [
+                'amount' => 160.0,
+                'checkout' => '',
+                'fine' => 'Vormerkgebuehr',
+                'balance' => 160.0,
+                'createdate' => '06-07-2016',
+                'duedate' => '',
+                'id' => '',
+                'title' => null,
+                'feeid' => null,
+                'about' => 'Open source licensing : software freedom and intellectual property law ; [open source licensees are free to: use open source software for any purpose, make and distribute copies, create and distribute derivative works, access and use the source code, com / Rosen, Lawrence (c 2005)',
+                'item' => 'http://uri.gbv.de/document/opac-de-830:bar:830$28295402'
+            ],
+        1 =>
+            [
+                'amount' => 80.0,
+                'checkout' => '',
+                'fine' => 'Vormerkgebuehr',
+                'balance' => 80.0,
+                'createdate' => '05-23-2016',
+                'duedate' => '',
+                'id' => '',
+                'title' => null,
+                'feeid' => null,
+                'about' => 'Zend framework in action / Allen, Rob (2009)',
+                'item' => 'http://uri.gbv.de/document/opac-de-830:bar:830$28323471'
+            ],
+        2 =>
+            [
+                'amount' => 300.0,
+                'checkout' => '',
+                'fine' => 'Säumnisgebühr',
+                'balance' => 300.0,
+                'createdate' => '05-23-2016',
+                'duedate' => '',
+                'id' => '',
+                'title' => null,
+                'feeid' => null,
+                'about' => 'Unsere historischen Gärten / Lutze, Margot (1986)',
+                'item' => 'http://uri.gbv.de/document/opac-de-830:bar:830$24476416'
+            ],
+        3 =>
+            [
+                'amount' => 100.0,
+                'checkout' => '',
+                'fine' => 'Säumnisgebühr',
+                'balance' => 100.0,
+                'createdate' => '06-16-2016',
+                'duedate' => '',
+                'id' => '',
+                'title' => null,
+                'feeid' => null,
+                'about' => 'Triumphe des Backsteins = Triumphs of brick / (1992)',
+                'item' => 'http://uri.gbv.de/document/opac-de-830:bar:830$33204941'
+            ],
+        4 =>
+            [
+                'amount' => 100.0,
+                'checkout' => '',
+                'fine' => 'Säumnisgebühr',
+                'balance' => 100.0,
+                'createdate' => '05-23-2016',
+                'duedate' => '',
+                'id' => '',
+                'title' => null,
+                'feeid' => null,
+                'about' => 'Lehrbuch der Botanik / Strasburger, Eduard (2008)',
+                'item' => 'http://uri.gbv.de/document/opac-de-830:bar:830$26461872'
+            ],
+    ];
+
+    protected $holdsTestResult = [
+        0 =>
+            [
+                'item_id' => 'http://uri.gbv.de/document/opac-de-830:bar:830$34096983',
+                'cancel_details' => '',
+                'id' => 'http://uri.gbv.de/document/opac-de-830:ppn:040445623',
+                'type' => 'provided',
+                'location' => 'Test-Theke',
+                'position' => 0,
+                'available' => true,
+                'title' => 'Praktikum über Entwurf und Manipulation von Datenbanken : SQL/DS (IBM), UDS (Siemens) und MEMODAX / Vossen, Gottfried (1986)',
+                'callnumber' => '34:3409-6983',
+                'create' => '06-17-2016',
+                'expire' => '',
+            ],
+        1 =>
+            [
+                'item_id' => 'http://uri.gbv.de/document/opac-de-830:bar:830$28295402',
+                'cancel_details' => 'http://uri.gbv.de/document/opac-de-830:bar:830$28295402',
+                'id' => 'http://uri.gbv.de/document/opac-de-830:ppn:391260316',
+                'type' => 'reserved',
+                'location' => 'Ausleihe',
+                'position' => 0,
+                'available' => false,
+                'title' => 'Open source licensing : software freedom and intellectual property law ; [open source licensees are free to: use open source software for any purpose, make and distribute copies, create and distribute derivative works, access and use the source code, com / Rosen, Lawrence (c 2005)',
+                'callnumber' => '28:2829-5402',
+                'create' => '06-15-2016',
+                'duedate' => '06-15-2016',
+            ],
+    ];
+
+    protected $requestsTestResult = [
+        0 =>
+            [
+                'item_id' => 'http://uri.gbv.de/document/opac-de-830:bar:830$24260127',
+                'cancel_details' => '',
+                'id' => 'http://uri.gbv.de/document/opac-de-830:ppn:020966334',
+                'type' => 'ordered',
+                'location' => 'Ausleihe',
+                'position' => 0,
+                'available' => false,
+                'title' => 'Gold / Kettell, Brian (1982)',
+                'callnumber' => '24:2426-0127',
+                'create' => '04-25-2016',
+            ],
+    ];
+
+    protected $transactionsTestResult = [
+        0 =>
+            [
+                'item_id' => 'http://uri.gbv.de/document/opac-de-830:bar:830$28342436',
+                'id' => 'http://uri.gbv.de/document/opac-de-830:ppn:58891861X',
+                'title' => 'Theoretische Informatik : mit 22 Tabellen und 78 Aufgaben / Hoffmann, Dirk W. (2009)',
+                'callnumber' => '28:2834-2436',
+                'renewable' => false,
+                'renew_details' => '',
+                'request' => 0,
+                'renew' => 12,
+                'reminder' => 1,
+                'startTime' => '11-15-2013',
+                'dueTime' => '06-15-2016',
+                'duedate' => '',
+                'message' => '',
+                'borrowingLocation' => 'Ausleihe',
+            ],
+        1 =>
+            [
+                'renewable' => false,
+                'item_id' => 'http://uri.gbv.de/document/opac-de-830:bar:830$22278001',
+                'renew_details' => '',
+                'id' => 'http://uri.gbv.de/document/opac-de-830:ppn:659228084',
+                'title' => 'Linked Open Library Data : bibliographische Daten und ihre Zugänglichkeit im Web der Daten ; Innovationspreis 2011 / Fürste, Fabian M. (2011)',
+                'request' => 0,
+                'renew' => 9,
+                'reminder' => 0,
+                'startTime' => '12-22-2011',
+                'dueTime' => '07-14-2016',
+                'duedate' => '',
+                'message' => '',
+                'borrowingLocation' => 'Ausleihe',
+                'callnumber' => '22:2227-8001',
+            ]
+    ];
+
+    protected $renewTestResult = [
+        'blocks' => false,
+        'details' => [
+            'http://uri.gbv.de/document/opac-de-830:bar:830$22061137' => [
+                'success' => true,
+                'new_date' => "07-18-2016",
+                'item_id' => 0,
+                'sysMessage' => "Successfully renewed"
+            ]
+        ]
+    ];
+
+    protected $storageRetrievalTestResult = [
+        'success' => true,
+        'sysMessage' => 'Successfully requested'
+    ];
+
+    protected $pwchangeTestResult = [
+        'success' => true,
+        'status' => "Successfully changed"
+    ];
+
+    protected $profileTestResult = [
+        'firstname' => "Nobody",
+        'lastname' => "Nothing",
+        'address1' => NULL,
+        'address2' => NULL,
+        'city' => NULL,
+        'country' => NULL,
+        'zip' => NULL,
+        'phone' => NULL,
+        'group' => NULL,
+        'expires' => "12-31-9999",
+        'statuscode' => 0,
+        'canWrite' => true
+    ];
+
+    /******************* Test cases ***************/
+    /*
+     ok changePassword
+     ok checkRequestIsValid
+     ok checkStorageRetrievalRequestIsValid
+     ok getMyProfile
+     ok getMyFines
+     ok getMyHolds
+     ok getMyTransactions
+     ok getRenewDetails
+     ok getMyStorageRetrievalRequests
+     ok placeHold
+     ok renewMyItems
+     ok placeStorageRetrievalRequest
+     */
+
+    /**
+     * Constructor
+     */
+    public function __construct()
+    {
+        $this->driver = $this->createConnector();
+    }
+
+    /**
+     * Test
+     *
+     * @return void
+     */
+    public function testChangePassword()
+    {
+        $changePasswordTestdata = [
+            "patron" => [
+                "cat_username" => "08301001001"
+             ],
+             "oldPassword" => "oldsecret",
+             "newPassword" => "newsecret"
+        ];
+
+        $conn = $this->createConnector('changePassword.json');
+        $conn->setConfig($this->validConfig);
+        $conn->init();
+        $result = $conn->changePassword($changePasswordTestdata);
+        $this->assertEquals($this->pwchangeTestResult, $result);
+    }
+
+    /**
+     * Test
+     *
+     * @return void
+     */
+    public function testFees()
+    {
+        $conn = $this->createConnector('fees.json');
+        $conn->setConfig($this->validConfig);
+        $conn->init();
+        $result = $conn->getMyFines($this->patron);
+
+        $this->assertEquals($this->feeTestResult, $result);
+    }
+
+    /**
+     * Test
+     *
+     * @return void
+     */
+    public function testHolds()
+    {
+        $conn = $this->createConnector('items.json');
+        $conn->setConfig($this->validConfig);
+        $conn->init();
+        $result = $conn->getMyHolds($this->patron);
+
+        $this->assertEquals($this->holdsTestResult, $result);
+    }
+
+    /**
+     * Test
+     *
+     * @return void
+     */
+    public function testRequests()
+    {
+        $conn = $this->createConnector('items.json');
+        $conn->setConfig($this->validConfig);
+        $conn->init();
+        $result = $conn->getMyStorageRetrievalRequests($this->patron);
+
+        $this->assertEquals($this->requestsTestResult, $result);
+    }
+
+    /**
+     * Test
+     *
+     * @return void
+     */
+    public function testTransactions()
+    {
+        $conn = $this->createConnector('items.json');
+        $conn->setConfig($this->validConfig);
+        $conn->init();
+        $result = $conn->getMyTransactions($this->patron);
+
+        $this->assertEquals($this->transactionsTestResult, $result);
+    }
+
+    /**
+     * Test
+     *
+     * @return void
+     */
+    public function testProfile()
+    {
+        $conn = $this->createMockConnector('patron.json');
+        $result = $conn->getMyProfile($this->patron);
+
+        $this->assertEquals($this->profileTestResult, $result);
+    }
+
+    /**
+     * Test
+     *
+     * @return void
+     */
+    public function testValidRequest()
+    {
+        $conn = $this->createMockConnector('patron.json');
+
+        $result = $conn->checkRequestIsValid(
+            'http://paia.gbv.de/', [], $this->patron
+        );
+        $resultStorageRetrieval = $conn->checkStorageRetrievalRequestIsValid(
+            'http://paia.gbv.de/', [], $this->patron
+        );
+        $result_bad = $conn->checkRequestIsValid(
+            'http://paia.gbv.de/', [], $this->patron_bad
+        );
+        $resultStorage_bad = $conn->checkStorageRetrievalRequestIsValid(
+            'http://paia.gbv.de/', [], $this->patron_bad
+        );
+        $result_expired = $conn->checkRequestIsValid(
+            'http://paia.gbv.de/', [], $this->patron_expired
+        );
+        $resultStorage_expired = $conn->checkStorageRetrievalRequestIsValid(
+            'http://paia.gbv.de/', [], $this->patron_expired
+        );
+
+        $this->assertEquals(true, $result);
+        $this->assertEquals(true, $resultStorageRetrieval);
+        $this->assertEquals(false, $result_bad);
+        $this->assertEquals(false, $resultStorage_bad);
+        $this->assertEquals(false, $result_expired);
+        $this->assertEquals(false, $resultStorage_expired);
+    }
+    /**
+     * Test
+     *
+     * @return void
+     */
+    public function testRenewDetails()
+    {
+        $conn = $this->createConnector('');
+        $conn->setConfig($this->validConfig);
+        $conn->init();
+        $result = $conn->getRenewDetails($this->transactionsTestResult[1]);
+
+        $this->assertEquals('', $result);
+    }
+
+    /**
+     * Test
+     *
+     * @return void
+     */
+    public function testPlaceHold()
+    {
+        $sr_request = [
+            "item_id"     => "http://uri.gbv.de/document/opac-de-830:bar:830$24014292",
+            "patron" => [
+                "cat_username" => "08301001001"
+            ]
+        ];
+
+        $conn = $this->createConnector('storageretrieval.json');
+        $conn->setConfig($this->validConfig);
+        $conn->init();
+        $result = $conn->placeHold($sr_request);
+        $this->assertEquals($this->storageRetrievalTestResult, $result);
+    }
+
+    /**
+     * Test
+     *
+     * @return void
+     */
+    public function testPlaceStorageRetrievalRequest()
+    {
+        $sr_request = [
+            "item_id"     => "http://uri.gbv.de/document/opac-de-830:bar:830$24014292",
+            "patron" => [
+                "cat_username" => "08301001001"
+            ]
+        ];
+
+        $conn = $this->createConnector('storageretrieval.json');
+        $conn->setConfig($this->validConfig);
+        $conn->init();
+        $result = $conn->placeStorageRetrievalRequest($sr_request);
+        $this->assertEquals($this->storageRetrievalTestResult, $result);
+    }
+
+    /**
+     * Test
+     *
+     * @return void
+     */
+    public function testRenew()
+    {
+        $renew_request = [
+            "details" => [
+                "item"     => "http://uri.gbv.de/document/opac-de-830:bar:830$22061137"
+            ],
+            "patron" => [
+                "cat_username" => "08301001001"
+            ]
+        ];
+
+        $conn = $this->createConnector('renew_ok.json');
+        $conn->setConfig($this->validConfig);
+        $conn->init();
+        $result = $conn->renewMyItems($renew_request);
+
+        $this->assertEquals($this->renewTestResult, $result);
+
+    /* TODO: make me work
+        $conn_fail = $this->createConnector('renew_error.json');
+        $connfail->setConfig($this->validConfig);
+        $conn_fail->init();
+        $result_fail = $conn_fail->renewMyItems($renew_request);
+
+        $this->assertEquals($this->failedRenewTestResult, $result_fail);
+    */
+    }
+
+    /**
+     * Create connector with fixture file.
+     *
+     * @param string $fixture Fixture file
+     *
+     * @return Connector
+     *
+     * @throws InvalidArgumentException Fixture file does not exist
+     */
+    protected function createConnector($fixture = null)
+    {
+        $adapter = new TestAdapter();
+        if ($fixture) {
+            $file = realpath(
+                __DIR__ .
+                '/../../../../../../tests/fixtures/paia/response/' . $fixture
+            );
+            if (!is_string($file) || !file_exists($file) || !is_readable($file)) {
+                throw new InvalidArgumentException(
+                    sprintf('Unable to load fixture file: %s ', $file)
+                );
+            }
+            $response = file_get_contents($file);
+            $responseObj = HttpResponse::fromString($response);
+            $adapter->setResponse($responseObj);
+        }
+        $service = new \VuFindHttp\HttpService();
+        $service->setDefaultAdapter($adapter);
+        $conn = new PAIA(
+            new \VuFind\Date\Converter(),
+            new \Zend\Session\SessionManager()
+        );
+        $conn->setHttpService($service);
+        return $conn;
+    }
+
+    /**
+     * Create connector with fixture file.
+     *
+     * @param string $fixture Fixture file
+     *
+     * @return Connector
+     *
+     * @throws InvalidArgumentException Fixture file does not exist
+     */
+    protected function createMockConnector($fixture = null)
+    {
+        $adapter = new TestAdapter();
+        if ($fixture) {
+            $file = realpath(
+                __DIR__ .
+                '/../../../../../../tests/fixtures/paia/response/' . $fixture
+            );
+            if (!is_string($file) || !file_exists($file) || !is_readable($file)) {
+                throw new InvalidArgumentException(
+                    sprintf('Unable to load fixture file: %s ', $file)
+                );
+            }
+            $response = file_get_contents($file);
+            $responseObj = HttpResponse::fromString($response);
+            $adapter->setResponse($responseObj);
+        }
+        $service = new \VuFindHttp\HttpService();
+        $service->setDefaultAdapter($adapter);
+        $conn = $this->getMockBuilder('VuFind\ILS\Driver\PAIA')
+            ->setConstructorArgs([ new \VuFind\Date\Converter(),
+                new \Zend\Session\SessionManager()
+            ])
+            ->setMethods(['getScope'])
+            ->getMock();
+        $conn->expects($this->any())->method('getScope')
+            ->will($this->returnValue([ 'write_items' ]));
+        $conn->setHttpService($service);
+        $conn->setConfig($this->validConfig);
+        $conn->init();
+        return $conn;
+    }
+}
-- 
GitLab