LP#1541559: ebook API service and test module
authorJeff Davis <jdavis@sitka.bclibraries.ca>
Tue, 7 Feb 2017 23:08:31 +0000 (15:08 -0800)
committerKathy Lussier <klussier@masslnc.org>
Mon, 20 Feb 2017 23:54:38 +0000 (18:54 -0500)
This commit introduces a new service, open-ils.ebook_api, for
integration of third-party APIs from vendors like OverDrive and
OneClickdigital.

The design of the service is somewhat similar to Evergreen's added
content module.  Common functionality and API calls are defined in the
main Perl module, OpenILS::Application::EbookAPI, while vendor-specific
details like endpoint URLs are broken out into separate "handler"
submodules for each vendor API.  (The actual mechanics of HTTP
requests/responses are handled by the new OpenILS::Utils::HTTPClient
module.)

An example handler module, OpenILS::Application::EbookAPI::Test, is
included with this commit, along with some live tests which depend on
the test module.  It can be considered a reference implementation for
future vendor-specific handlers.

Signed-off-by: Jeff Davis <jdavis@sitka.bclibraries.ca>
Signed-off-by: Kathy Lussier <klussier@masslnc.org>
Open-ILS/examples/opensrf.xml.example
Open-ILS/examples/opensrf_core.xml.example
Open-ILS/src/perlmods/MANIFEST
Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/Test.pm [new file with mode: 0644]
Open-ILS/src/perlmods/live_t/20-lp1541559-ebook-api.t [new file with mode: 0644]
Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t [new file with mode: 0644]

index dd128bd..a111c5b 100644 (file)
@@ -1247,7 +1247,27 @@ vim:et:ts=4:sw=4:
                 </app_settings>
             </open-ils.hold-targeter>
 
-
+            <open-ils.ebook_api>
+                <keepalive>5</keepalive>
+                <stateless>1</stateless>
+                <language>perl</language>
+                <implementation>OpenILS::Application::EbookAPI</implementation>
+                <max_requests>100</max_requests>
+                <unix_config>
+                    <unix_sock>ebook_api_unix.sock</unix_sock>
+                    <unix_pid>ebook_api_unix.pid</unix_pid>
+                    <unix_log>ebook_api_unix.log</unix_log>
+                    <max_requests>100</max_requests>
+                    <min_children>1</min_children>
+                    <max_children>15</max_children>
+                    <min_spare_children>1</min_spare_children>
+                    <max_spare_children>5</max_spare_children>
+                </unix_config>
+                <app_settings>
+                  <cache_timeout>300</cache_timeout>
+                  <request_timeout>60</request_timeout>
+                </app_settings>
+            </open-ils.ebook_api>
         </apps>
     </default>
 
@@ -1293,6 +1313,7 @@ vim:et:ts=4:sw=4:
                 <appname>open-ils.vandelay</appname>  
                 <appname>open-ils.serial</appname>  
                 <appname>open-ils.hold-targeter</appname>  
+                <appname>open-ils.ebook_api</appname>
             </activeapps>
         </localhost>
     </hosts>
index d2ec8eb..ba21693 100644 (file)
@@ -37,6 +37,7 @@ Example OpenSRF bootstrap configuration file for Evergreen
           <service>open-ils.url_verify</service>
           <service>open-ils.vandelay</service>
           <service>open-ils.serial</service>
+          <service>open-ils.ebook_api</service>
         </services>
       </router>
 
index 216c40d..8de8df8 100644 (file)
@@ -39,6 +39,8 @@ lib/OpenILS/Application/Circ/StatCat.pm
 lib/OpenILS/Application/Circ/Survey.pm
 lib/OpenILS/Application/Circ/Transit.pm
 lib/OpenILS/Application/Collections.pm
+lib/OpenILS/Application/EbookAPI.pm
+lib/OpenILS/Application/EbookAPI/Test.pm
 lib/OpenILS/Application/Fielder.pm
 lib/OpenILS/Application/PermaCrud.pm
 lib/OpenILS/Application/Proxy.pm
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI.pm
new file mode 100644 (file)
index 0000000..1b3c8c6
--- /dev/null
@@ -0,0 +1,811 @@
+#!/usr/bin/perl
+
+# Copyright (C) 2015 BC Libraries Cooperative
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+# 
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# 
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+# ====================================================================== 
+# We define a handler class for each vendor API (OneClickdigital, OverDrive, etc.).
+# See EbookAPI/Test.pm for a reference implementation with required methods,
+# arguments, and return values.
+# ====================================================================== 
+
+package OpenILS::Application::EbookAPI;
+
+use strict;
+use warnings;
+
+use Time::HiRes qw/gettimeofday/;
+use Digest::MD5 qw/md5_hex/;
+
+use OpenILS::Application;
+use base qw/OpenILS::Application/;
+use OpenSRF::AppSession;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenSRF::EX qw(:try);
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Logger qw($logger);
+use OpenSRF::Utils::Cache;
+use OpenSRF::Utils::JSON;
+use OpenILS::Utils::HTTPClient;
+
+my $handler;
+my $cache;
+my $cache_timeout;
+my $default_request_timeout;
+
+# map EbookAPI vendor codes to corresponding packages
+our %vendor_handlers = (
+    'ebook_test' => 'OpenILS::Application::EbookAPI::Test',
+    'oneclickdigital' => 'OpenILS::Application::EbookAPI::OneClickdigital',
+    'overdrive' => 'OpenILS::Application::EbookAPI::OverDrive'
+);
+
+sub initialize {
+    $cache = OpenSRF::Utils::Cache->new;
+
+    my $sclient = OpenSRF::Utils::SettingsClient->new();
+    $cache_timeout = $sclient->config_value("apps", "open-ils.ebook_api", "app_settings", "cache_timeout" ) || 300;
+    $default_request_timeout = $sclient->config_value("apps", "open-ils.ebook_api", "app_settings", "request_timeout" ) || 60;
+}
+
+# returns the cached object (if successful)
+sub update_cache {
+    my $cache_obj = shift;
+    my $overlay = shift || 0;
+    my $cache_key;
+    if ($cache_obj->{session_id}) {
+        $cache_key = $cache_obj->{session_id};
+    } else {
+        $logger->error("EbookAPI: cannot update cache with unknown cache object");
+        return;
+    }
+
+    # Optionally, keep old cached field values unless a new value for that
+    # field is explicitly provided.  This makes it easier for asynchronous
+    # requests (e.g. for circs and holds) to cache their results.
+    if ($overlay) {
+        if (my $orig_cache = $cache->get_cache($cache_key)) {
+            $logger->info("EbookAPI: overlaying new values on existing cache object");
+            foreach my $k (%$cache_obj) {
+                # Add/overwrite existing cached value if a new value is defined.
+                $orig_cache->{$k} = $cache_obj->{$k} if (defined $cache_obj->{$k});
+            }
+            # The cache object we want to save is the (updated) original one.
+            $cache_obj = $orig_cache;
+        }
+    }
+
+    try { # fail silently if there's no pre-existing cache to delete
+        $cache->delete_cache($cache_key);
+    } catch Error with {};
+    if (my $success_key = $cache->put_cache($cache_key, $cache_obj, $cache_timeout)) {
+        return $cache->get_cache($success_key);
+    } else {
+        $logger->error("EbookAPI: error when updating cache with object");
+        return;
+    }
+}
+
+sub retrieve_session {
+    my $session_id = shift;
+    unless ($session_id) {
+        $logger->info("EbookAPI: no session ID provided");
+        return;
+    }
+    my $cached_session = $cache->get_cache($session_id) || undef;
+    if ($cached_session) {
+        return $cached_session;
+    } else {
+        $logger->info("EbookAPI: could not find cached session with id $session_id");
+        return;
+    }
+}
+
+# prepare new handler from session
+# (will retrieve cached session unless a session object is provided)
+sub new_handler {
+    my $session_id = shift;
+    my $ses = shift || retrieve_session($session_id);
+    if (!$ses) {
+        $logger->error("EbookAPI: could not start handler - no cached session with ID $session_id");
+        return;
+    }
+    my $module = ref($ses);
+    $logger->info("EbookAPI: starting new $module handler from cached session $session_id...");
+    $module->use;
+    my $handler = $module->new($ses);
+    return $handler;
+}
+
+
+sub check_session {
+    my $self = shift;
+    my $conn = shift;
+    my $session_id = shift;
+    my $vendor = shift;
+    my $ou = shift;
+
+    return start_session($self, $conn, $vendor, $ou) unless $session_id;
+
+    my $cached_session = retrieve_session($session_id);
+    if ($cached_session) {
+        # re-authorize cached session, if applicable
+        my $handler = new_handler($session_id, $cached_session);
+        $handler->do_client_auth();
+        if (update_cache($handler)) {
+            return $session_id;
+        } else {
+            $logger->error("EbookAPI: error updating session cache");
+            return;
+        }
+    } else {
+        return start_session($self, $conn, $vendor, $ou);
+    }
+}
+__PACKAGE__->register_method(
+    method => 'check_session',
+    api_name => 'open-ils.ebook_api.check_session',
+    api_level => 1,
+    argc => 2,
+    signature => {
+        desc => "Validate an existing EbookAPI session, or initiate a new one",
+        params => [
+            {
+                name => 'session_id',
+                desc => 'The EbookAPI session ID being checked',
+                type => 'string'
+            },
+            {
+                name => 'vendor',
+                desc => 'The ebook vendor (e.g. "oneclickdigital")',
+                type => 'string'
+            },
+            {
+                name => 'ou',
+                desc => 'The context org unit ID',
+                type => 'number'
+            }
+        ],
+        return => {
+            desc => 'Returns an EbookAPI session ID',
+            type => 'string'
+        }
+    }
+);
+
+sub _start_session {
+    my $vendor = shift;
+    my $ou = shift;
+    $ou = $ou || 1; # default to top-level org unit
+
+    my $module;
+    
+    # determine EbookAPI handler from vendor name
+    # TODO handle API versions?
+    if ($vendor_handlers{$vendor}) {
+        $module = $vendor_handlers{$vendor};
+    } else {
+        $logger->error("EbookAPI: No handler module found for $vendor!");
+        return;
+    }
+
+    # TODO cache session? reuse an existing one if available?
+
+    # generate session ID
+    my ($sec, $usec) = gettimeofday();
+    my $r = rand();
+    my $session_id = "ebook_api.ses." . md5_hex("$sec-$usec-$r");
+    
+    my $args = {
+        vendor => $vendor,
+        ou => $ou,
+        session_id => $session_id
+    };
+
+    $module->use;
+    $handler = $module->new($args);  # create new handler object
+    $handler->initialize();          # set handler attributes
+    $handler->do_client_auth();      # authorize client session against API, if applicable
+
+    # our "session" is actually just our handler object, serialized and cached
+    my $ckey = $handler->{session_id};
+    $cache->put_cache($ckey, $handler, $cache_timeout);
+
+    return $handler->{session_id};
+}
+
+sub start_session {
+    my $self = shift;
+    my $conn = shift;
+    my $vendor = shift;
+    my $ou = shift;
+    return _start_session($vendor, $ou);
+}
+__PACKAGE__->register_method(
+    method => 'start_session',
+    api_name => 'open-ils.ebook_api.start_session',
+    api_level => 1,
+    argc => 1,
+    signature => {
+        desc => "Initiate an EbookAPI session",
+        params => [
+            {
+                name => 'vendor',
+                desc => 'The ebook vendor (e.g. "oneclickdigital")',
+                type => 'string'
+            },
+            {
+                name => 'ou',
+                desc => 'The context org unit ID',
+                type => 'number'
+            }
+        ],
+        return => {
+            desc => 'Returns an EbookAPI session ID',
+            type => 'string'
+        }
+    }
+);
+
+sub cache_patron_password {
+    my $self = shift;
+    my $conn = shift;
+    my $session_id = shift;
+    my $password = shift;
+
+    # We don't need the handler module for this.
+    # Let's just update the cache directly.
+    if (my $ses = $cache->get_cache($session_id)) {
+        $ses->{patron_password} = $password;
+        if (update_cache($ses)) {
+            return $session_id;
+        } else {
+            $logger->error("EbookAPI: there was an error caching patron password");
+            return;
+        }
+    }
+}
+__PACKAGE__->register_method(
+    method => 'cache_patron_password',
+    api_name => 'open-ils.ebook_api.patron.cache_password',
+    api_level => 1,
+    argc => 2,
+    signature => {
+        desc => "Cache patron password on login for use during EbookAPI patron authentication",
+        params => [
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'patron_password',
+                desc => 'The patron password',
+                type => 'string'
+            }
+        ],
+        return => { desc => 'A session key, or undef' }
+    }
+);
+
+# Submit an HTTP request to a specified API endpoint.
+#
+# Params:
+#
+#   $req - hashref containing the following:
+#       method: HTTP request method (defaults to GET)
+#       uri: API endpoint URI (required)
+#       header: arrayref of HTTP headers (optional, but see below)
+#       content: content of HTTP request (optional)
+#       request_timeout (defaults to value in opensrf.xml)
+#   $session_id - id of cached EbookAPI session
+#
+# A "Content-Type: application/json" header is automatically added to each
+# request.  If no Authorization header is provided via the $req param, the
+# following header will also be automatically added:
+#
+#   Authorization: basic $basic_token
+#
+# ... where $basic_token is derived from the cached session identified by the
+# $session_id param.  If this does not meet the needs of your API, include the
+# correct Authorization header in $req->{header}.
+sub request {
+    my $self = shift;
+    my $req = shift;
+    my $session_id = shift;
+
+    my $uri;
+    if (!defined ($req->{uri})) {
+        $logger->error('EbookAPI: attempted an HTTP request but no URI was provided');
+        return;
+    } else {
+        $uri = $req->{uri};
+    }
+    
+    my $method = defined $req->{method} ? $req->{method} : 'GET';
+    my $headers = defined $req->{headers} ? $req->{headers} : {};
+    my $content = defined $req->{content} ? $req->{content} : undef;
+    my $request_timeout = defined $req->{request_timeout} ? $req->{request_timeout} : $default_request_timeout;
+
+    # JSON as default content type
+    if ( !defined ($headers->{'Content-Type'}) ) {
+        $headers->{'Content-Type'} = 'application/json';
+    }
+
+    # all requests also require an Authorization header;
+    # let's default to using our basic token, if available
+    if ( !defined ($headers->{'Authorization'}) ) {
+        if (!$session_id) {
+            $logger->error("EbookAPI: HTTP request requires session info but no session ID was provided");
+            return;
+        }
+        my $ses = retrieve_session($session_id);
+        if ($ses) {
+            my $basic_token = $ses->{basic_token};
+            $headers->{'Authorization'} = "basic $basic_token";
+        }
+    }
+
+    my $client = OpenILS::Utils::HTTPClient->new();
+    my $res = $client->request(
+        $method,
+        $uri,
+        $headers,
+        $content,
+        $request_timeout
+    );
+    if (!defined ($res)) {
+        $logger->error('EbookAPI: no HTTP response received');
+        return;
+    } else {
+        $logger->info("EbookAPI: response received from server: " . $res->status_line);
+        return {
+            is_success => $res->is_success,
+            status     => $res->status_line,
+            content    => OpenSRF::Utils::JSON->JSON2perl($res->decoded_content)
+        };
+    }
+}
+
+sub get_availability {
+    my ($self, $conn, $session_id, $title_id) = @_;
+    my $handler = new_handler($session_id);
+    return $handler->do_availability_lookup($title_id);
+}
+__PACKAGE__->register_method(
+    method => 'get_availability',
+    api_name => 'open-ils.ebook_api.title.availability',
+    api_level => 1,
+    argc => 2,
+    signature => {
+        desc => "Get availability info for an ebook title",
+        params => [
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The title ID (ISBN, unique identifier, etc.)',
+                type => 'string'
+            }
+        ],
+        return => {
+            desc => 'Returns 1 if title is available, 0 if not available, or undef if availability info could not be retrieved',
+            type => 'number'
+        }
+    }
+);
+
+sub get_holdings {
+    my ($self, $conn, $session_id, $title_id) = @_;
+    my $handler = new_handler($session_id);
+    return $handler->do_holdings_lookup($title_id);
+}
+__PACKAGE__->register_method(
+    method => 'get_holdings',
+    api_name => 'open-ils.ebook_api.title.holdings',
+    api_level => 1,
+    argc => 2,
+    signature => {
+        desc => "Get detailed holdings info (copy counts and formats) for an ebook title, or basic availability if holdings info is unavailable",
+        params => [
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The title ID (ISBN, unique identifier, etc.)',
+                type => 'string'
+            }
+        ],
+        return => {
+            desc => 'Returns a hashref of holdings info with one or more of the following keys: available (0 or 1), copies_owned, copies_available, formats (arrayref of strings)',
+            type => 'hashref'
+        }
+    }
+);
+
+# Wrapper function for performing transactions that require an authenticated
+# patron and a title identifier (checkout, checkin, renewal, etc).
+#
+# Params:
+# - title_id: ISBN (OneClickdigital), title identifier (OverDrive)
+# - barcode: patron barcode
+#
+sub do_xact {
+    my ($self, $conn, $auth, $session_id, $title_id, $barcode) = @_;
+
+    my $action;
+    if ($self->api_name =~ /checkout/) {
+        $action = 'checkout';
+    } elsif ($self->api_name =~ /checkin/) {
+        $action = 'checkin';
+    } elsif ($self->api_name =~ /renew/) {
+        $action = 'renew';
+    } elsif ($self->api_name =~ /place_hold/) {
+        $action = 'place_hold';
+    } elsif ($self->api_name =~ /cancel_hold/) {
+        $action = 'cancel_hold';
+    }
+    $logger->info("EbookAPI: doing $action for title $title_id...");
+
+    # verify that user is authenticated in EG
+    my $e = new_editor(authtoken => $auth);
+    if (!$e->checkauth) {
+        $logger->error("EbookAPI: authentication failed: " . $e->die_event);
+        return;
+    }
+
+    my $handler = new_handler($session_id);
+    my $user_token = $handler->do_patron_auth($barcode);
+
+    # handler method constructs and submits request (and handles any external authentication)
+    my $res = $handler->$action($title_id, $user_token);
+    if (defined ($res)) {
+        return $res;
+    } else {
+        $logger->error("EbookAPI: could not do $action for title $title_id and patron $barcode");
+        return;
+    }
+}
+__PACKAGE__->register_method(
+    method => 'do_xact',
+    api_name => 'open-ils.ebook_api.checkout',
+    api_level => 1,
+    argc => 4,
+    signature => {
+        desc => "Checkout an ebook title to a patron",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The identifier of the title',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron to whom the title will be checked out',
+                type => 'string'
+            },
+        ],
+        return => {
+            desc => 'Success: { due_date => "2017-01-01" } / Failure: { error_msg => "Checkout limit reached." }',
+            type => 'hashref'
+        }
+    }
+);
+__PACKAGE__->register_method(
+    method => 'do_xact',
+    api_name => 'open-ils.ebook_api.renew',
+    api_level => 1,
+    argc => 4,
+    signature => {
+        desc => "Renew an ebook title for a patron",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The identifier of the title to be renewed',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron to whom the title is checked out',
+                type => 'string'
+            },
+        ],
+        return => {
+            desc => 'Success: { due_date => "2017-01-01" } / Failure: { error_msg => "Renewal limit reached." }',
+            type => 'hashref'
+        }
+    }
+);
+__PACKAGE__->register_method(
+    method => 'do_xact',
+    api_name => 'open-ils.ebook_api.checkin',
+    api_level => 1,
+    argc => 4,
+    signature => {
+        desc => "Check in an ebook title for a patron",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The identifier of the title to be checked in',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron to whom the title is checked out',
+                type => 'string'
+            },
+        ],
+        return => {
+            desc => 'Success: { } / Failure: { error_msg => "Checkin failed." }',
+            type => 'hashref'
+        }
+    }
+);
+__PACKAGE__->register_method(
+    method => 'do_xact',
+    api_name => 'open-ils.ebook_api.place_hold',
+    api_level => 1,
+    argc => 4,
+    signature => {
+        desc => "Place a hold on an ebook title for a patron",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The identifier of the title',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron for whom the title is being held',
+                type => 'string'
+            },
+        ],
+        return => {
+            desc => 'Success: { queue_position => 1, queue_size => 1, expire_date => "2017-01-01" } / Failure: { error_msg => "Could not place hold." }',
+            type => 'hashref'
+        }
+    }
+);
+__PACKAGE__->register_method(
+    method => 'do_xact',
+    api_name => 'open-ils.ebook_api.cancel_hold',
+    api_level => 1,
+    argc => 4,
+    signature => {
+        desc => "Cancel a hold on an ebook title for a patron",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'title_id',
+                desc => 'The identifier of the title',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron',
+                type => 'string'
+            },
+        ],
+        return => {
+            desc => 'Success: { } / Failure: { error_msg => "Could not cancel hold." }',
+            type => 'hashref'
+        }
+    }
+);
+
+sub _get_patron_xacts {
+    my ($xact_type, $auth, $session_id, $barcode) = @_;
+
+    $logger->info("EbookAPI: getting $xact_type for patron $barcode");
+
+    # verify that user is authenticated in EG
+    my $e = new_editor(authtoken => $auth);
+    if (!$e->checkauth) {
+        $logger->error("EbookAPI: authentication failed: " . $e->die_event);
+        return;
+    }
+
+    my $handler = new_handler($session_id);
+    my $user_token = $handler->do_patron_auth($barcode);
+
+    my $xacts;
+    if ($xact_type eq 'checkouts') {
+        $xacts = $handler->get_patron_checkouts($user_token);
+    } elsif ($xact_type eq 'holds') {
+        $xacts = $handler->get_patron_holds($user_token);
+    } else {
+        $logger->error("EbookAPI: invalid transaction type '$xact_type'");
+        return;
+    }
+
+    # cache and return transaction details
+    $handler->{$xact_type} = $xacts;
+    # Overlay transactions onto existing cached handler.
+    if (update_cache($handler, 1)) {
+        return $handler->{$xact_type};
+    } else {
+        $logger->error("EbookAPI: error caching transaction details ($xact_type)");
+        return;
+    }
+}
+
+sub get_patron_xacts {
+    my ($self, $conn, $auth, $session_id, $barcode) = @_;
+    my $xact_type;
+    if ($self->api_name =~ /checkouts/) {
+        $xact_type = 'checkouts';
+    } elsif ($self->api_name =~ /holds/) {
+        $xact_type = 'holds';
+    }
+    return _get_patron_xacts($xact_type, $auth, $session_id, $barcode);
+}
+__PACKAGE__->register_method(
+    method => 'get_patron_xacts',
+    api_name => 'open-ils.ebook_api.patron.get_checkouts',
+    api_level => 1,
+    argc => 3,
+    signature => {
+        desc => "Get information about a patron's ebook checkouts",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron',
+                type => 'string'
+            }
+        ],
+        return => {
+            desc => 'Returns an array of transaction details, or undef if no details available',
+            type => 'array'
+        }
+    }
+);
+__PACKAGE__->register_method(
+    method => 'get_patron_xacts',
+    api_name => 'open-ils.ebook_api.patron.get_holds',
+    api_level => 1,
+    argc => 3,
+    signature => {
+        desc => "Get information about a patron's ebook holds",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron',
+                type => 'string'
+            }
+        ],
+        return => {
+            desc => 'Returns an array of transaction details, or undef if no details available',
+            type => 'array'
+        }
+    }
+);
+
+sub get_all_patron_xacts {
+    my ($self, $conn, $auth, $session_id, $barcode) = @_;
+    my $checkouts = _get_patron_xacts('checkouts', $auth, $session_id, $barcode);
+    my $holds = _get_patron_xacts('holds', $auth, $session_id, $barcode);
+    return {
+        checkouts => $checkouts,
+        holds     => $holds
+    };
+}
+__PACKAGE__->register_method(
+    method => 'get_all_patron_xacts',
+    api_name => 'open-ils.ebook_api.patron.get_transactions',
+    api_level => 1,
+    argc => 3,
+    signature => {
+        desc => "Get information about a patron's ebook checkouts and holds",
+        params => [
+            {
+                name => 'authtoken',
+                desc => 'Authentication token',
+                type => 'string'
+            },
+            {
+                name => 'session_id',
+                desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+                type => 'string'
+            },
+            {
+                name => 'barcode',
+                desc => 'The barcode of the patron',
+                type => 'string'
+            }
+        ],
+        return => {
+            desc => 'Returns a hashref of transactions: { checkouts => [], holds => [], failed => [] }',
+            type => 'hashref'
+        }
+    }
+);
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/Test.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/Test.pm
new file mode 100644 (file)
index 0000000..a20846c
--- /dev/null
@@ -0,0 +1,464 @@
+#!/usr/bin/perl
+
+# Copyright (C) 2015 BC Libraries Cooperative
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+# 
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# 
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+# ====================================================================== 
+# OpenSRF requests are handled by the main OpenILS::Application::EbookAPI module,
+# which determines which "handler" submodule to use based on the params of the
+# OpenSRF request.  Each vendor API (OneClickdigital, OverDrive, etc.) has its
+# own separate handler class, since they all work a little differently.
+#
+# An instance of the handler class represents an EbookAPI session -- that is, we
+# instantiate a new handler object when we start a new session with the external API.
+# Thus everything we need to talk to the API, like client keys or auth tokens, is
+# an attribute of the handler object.
+#
+# API endpoints are defined in the handler class.  The handler constructs HTTP
+# requests, then passes them to the the request() method of the parent class
+# (OpenILS::Application::EbookAPI), which sets some default headers and manages
+# the actual mechanics of sending the request and receiving the response.  It's
+# up to the handler class to do something with the response.
+#
+# At a minimum, each handler must have the following methods, since the parent
+# class presumes they exist; it may be a no-op if the API doesn't support that
+# bit of functionality:
+#
+#   - initialize: assign values for basic attributes (e.g. library_id,
+#     basic_token) based on library settings
+#   - do_client_auth: authenticate client with external API (e.g. get client
+#     token if needed)
+#   - do_patron_auth: get a patron-specific bearer token, or just the patron ID
+#   - do_holdings_lookup: how many total/available "copies" are there for this
+#     title? (n/a for OneClickdigital)
+#   - do_availability_lookup: does this title have available "copies"? y/n
+#   - checkout
+#   - renew
+#   - checkin
+#   - place_hold
+#   - suspend_hold (n/a for OneClickdigital)
+#   - cancel_hold
+#   - get_patron_checkouts: returns an array of hashrefs representing checkouts;
+#     each checkout hashref has the following keys:
+#       - xact_id
+#       - title_id
+#       - due_date
+#       - download_url
+#       - title
+#       - author
+#   - get_patron_holds
+# ====================================================================== 
+
+package OpenILS::Application::EbookAPI::Test;
+
+use strict;
+use warnings;
+
+use OpenILS::Application;
+use OpenILS::Application::EbookAPI;
+use base qw/OpenILS::Application::EbookAPI/;
+use OpenSRF::AppSession;
+use OpenSRF::EX qw(:try);
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Logger qw($logger);
+use OpenSRF::Utils::Cache;
+use OpenILS::Application::AppUtils;
+use DateTime;
+use DateTime::Format::ISO8601;
+
+my $U = 'OpenILS::Application::AppUtils';
+
+# create new handler object
+sub new {
+    my( $class, $args ) = @_;
+
+    # A new handler object represents a new API session, so we instantiate it
+    # by passing it a hashref containing the following basic attributes
+    # available to us when we start the session:
+    #   - vendor: a string indicating the vendor whose API we're talking to
+    #   - ou: org unit ID for current session
+    #   - session_id: unique ID for the session represented by this object
+
+    $class = ref $class || $class;
+    return bless $args, $class;
+}
+
+# set API-specific handler attributes based on library settings
+sub initialize {
+    my $self = shift;
+
+    # At a minimum, you are likely to need some kind of basic API key or token
+    # to allow the client (Evergreen) to use the API.
+    # Other attributes will vary depending on the API.  Consult your API
+    # documentation for details.
+
+    return $self;
+}
+
+# authorize client session against API
+sub do_client_auth {
+    my $self = shift;
+
+    # Some APIs require client authorization, and may return an auth token
+    # which must be included in subsequent requests.  This is where you do
+    # that.  If you get an auth token, you'll want to add it as an attribute to
+    # the handler object so that it's available to use in subsequent requests.
+    # If your API doesn't require this step, you don't need to return anything
+    # here.
+
+    return;
+}
+
+# authenticate patron against API
+sub do_patron_auth {
+    my $self = shift;
+
+    # We authenticate the patron using the barcode of their active card.
+    # We may capture this on OPAC login (along with password, if required),
+    # in which case it should already be an attribute of the handler object;
+    # otherwise, it should be passed to this method as a parameter.
+    my $barcode = shift;
+    if ($barcode) {
+        if (!$self->{patron_barcode}) {
+            $self->{patron_barcode} = $barcode;
+        } elsif ($barcode ne $self->{patron_barcode}) {
+            $logger->error("EbookAPI: patron barcode in auth request does not match patron barcode for this session");
+            return;
+        }
+    } else {
+        if (!$self->{patron_barcode}) {
+            $logger->error("EbookAPI: Cannot authenticate patron with unknown barcode");
+        } else {
+            $barcode = $self->{patron_barcode};
+        }
+    }
+
+    # We really don't want to be handling the patron's unencrypted password.
+    # But if we need to, it should be added to our handler object on login
+    # via the open-ils.ebook_api.patron.cache_password OpenSRF API call
+    # before we attempt to authenticate the patron against the external API.
+    my $password;
+    if ($self->{patron_password}) {
+        $password = $self->{patron_password};
+    }
+
+    # return external patron ID or patron auth token
+
+    # For testing, only barcode 99999359616 is valid.
+    return 'USER001' if ($barcode eq '99999359616');
+
+    # All other values return undef.
+    return undef;
+}
+
+# get detailed holdings information (copy counts and formats), OR basic
+# availability if detailed info is not provided by the API
+sub do_holdings_lookup {
+    my $self = shift;
+
+    # External ID for title.  Depending on the API, this could be an ISBN
+    # or an identifier unique to that vendor.
+    my $title_id = shift;
+
+    # Prepare data structure to be used as return value.
+    # NOTE: If the external API does not provide detailed holdings info,
+    # return simple availability information: { available => 1 }
+    my $holdings = {
+        copies_owned => 0,
+        copies_available => 0,
+        formats => []
+    };
+
+    # 001 and 002 are unavailable.
+    if ($title_id eq '001' || $title_id eq '002') {
+        $holdings->{copies_owned} = 1;
+        $holdings->{copies_available} = 0;
+        push @{$holdings->{formats}}, 'ebook';
+    }
+
+    # 003 is available.
+    if ($title_id eq '003') {
+        $holdings->{copies_owned} = 1;
+        $holdings->{copies_available} = 1;
+        push @{$holdings->{formats}}, 'ebook';
+    }
+
+    # All other title IDs are unknown.
+
+    return $holdings;
+}
+
+# look up whether a title is currently available for checkout; returns a boolean value
+sub do_availability_lookup {
+    my $self = shift;
+
+    # External ID for title.  Depending on the API, this could be an ISBN
+    # or an identifier unique to that vendor.
+    my $title_id = shift;
+
+    # At this point, you would lookup title availability via an API request.
+    # In our case, since this is a test module, we just return availability info
+    # based on hard-coded values:
+
+    # 001 and 002 are unavailable.
+    return 0 if ($title_id eq '001');
+    return 0 if ($title_id eq '002');
+
+    # 003 is available.
+    return 1 if ($title_id eq '003');
+
+    # All other title IDs are unknown.
+    return undef;
+}
+
+# check out a title to a patron
+sub checkout {
+    my $self = shift;
+
+    # External ID of title to be checked out.
+    my $title_id = shift;
+
+    # Patron ID or patron auth token, as returned by do_patron_auth().
+    my $user_token = shift;
+
+    # If checkout succeeds, the response is a hashref with the following fields:
+    # - due_date
+    # - xact_id (optional)
+    #
+    # If checkout fails, the response is a hashref with the following fields:
+    # - error_msg: a string containing an error message or description of why
+    #   the checkout failed (e.g. "Checkout limit of (4) reached").
+    #
+    # If no valid response is received from the API, return undef.
+
+    # For testing purposes, user ID USER001 is our only valid user, 
+    # and title 003 is the only available title.
+    if ($title_id && $user_token) {
+        if ($user_token eq 'USER001' && $title_id eq '003') {
+            return { due_date => DateTime->today()->add( days => 14 )->iso8601() };
+        } else {
+            return { msg => 'Checkout failed.' };
+        }
+    } else {
+        return undef;
+    }
+
+}
+
+sub renew {
+    my $self = shift;
+
+    # External ID of title to be renewed.
+    my $title_id = shift;
+
+    # Patron ID or patron auth token, as returned by do_patron_auth().
+    my $user_token = shift;
+
+    # If renewal succeeds, the response is a hashref with the following fields:
+    # - due_date
+    # - xact_id (optional)
+    #
+    # If renewal fails, the response is a hashref with the following fields:
+    # - error_msg: a string containing an error message or description of why
+    #   the renewal failed (e.g. "Renewal limit reached").
+    #
+    # If no valid response is received from the API, return undef.
+
+    # For testing purposes, user ID USER001 is our only valid user, 
+    # and title 001 is the only renewable title.
+    if ($title_id && $user_token) {
+        if ($user_token eq 'USER001' && $title_id eq '001') {
+            return { due_date => DateTime->today()->add( days => 14 )->iso8601() };
+        } else {
+            return { error_msg => 'Renewal failed.' };
+        }
+    } else {
+        return undef;
+    }
+}
+
+sub checkin {
+    my $self = shift;
+
+    # External ID of title to be checked in.
+    my $title_id = shift;
+
+    # Patron ID or patron auth token, as returned by do_patron_auth().
+    my $user_token = shift;
+
+    # If checkin succeeds, return an empty hashref (actually it doesn't
+    # need to be empty, it just must NOT contain "error_msg" as a key).
+    #
+    # If checkin fails, return a hashref with the following fields:
+    # - error_msg: a string containing an error message or description of why
+    #   the checkin failed (e.g. "Checkin failed").
+    #
+    # If no valid response is received from the API, return undef.
+
+    # For testing purposes, user ID USER001 is our only valid user, 
+    # and title 003 is the only title that can be checked in.
+    if ($title_id && $user_token) {
+        if ($user_token eq 'USER001' && $title_id eq '003') {
+            return {};
+        } else {
+            return { error_msg => 'Checkin failed' };
+        }
+    } else {
+        return undef;
+    }
+}
+
+sub place_hold {
+    my $self = shift;
+
+    # External ID of title to be held.
+    my $title_id = shift;
+
+    # Patron ID or patron auth token, as returned by do_patron_auth().
+    my $user_token = shift;
+
+    # If hold is successfully placed, return a hashref with the following
+    # fields:
+    # - queue_position: this user's position in hold queue for this title
+    # - queue_size: total number of holds on this title
+    # - expire_date: when the hold expires
+    #
+    # If hold fails, return a hashref with the following fields:
+    # - error_msg: a string containing an error message or description of why
+    #   the hold failed (e.g. "Hold limit (4) reached").
+    #
+    # If no valid response is received from the API, return undef.
+
+    # For testing purposes, we always and only allow placing a hold on title
+    # 002 by user ID USER001.
+    if ($title_id && $user_token) {
+        if ($user_token eq 'USER001' && $title_id eq '002') {
+            return {
+                queue_position => 1,
+                queue_size => 1,
+                expire_date => DateTime->today()->add( days => 70 )->iso8601()
+            };
+        } else {
+            return { error_msg => 'Unable to place hold' };
+        }
+    } else {
+        return undef;
+    }
+}
+
+sub cancel_hold {
+    my $self = shift;
+
+    # External ID of title.
+    my $title_id = shift;
+
+    # Patron ID or patron auth token, as returned by do_patron_auth().
+    my $user_token = shift;
+
+    # If hold is successfully canceled, return an empty hashref (actually it
+    # doesn't need to be empty, it just must NOT contain "error_msg" as a key).
+    #
+    # If hold is NOT canceled, return a hashref with the following fields:
+    # - error_msg: a string containing an error message or description of why
+    #   the hold was not canceled (e.g. "Hold could not be canceled"). 
+    #
+    # If no valid response is received from the API, return undef.
+
+    # For testing purposes, we always and only allow canceling a hold on title
+    # 002 by user ID USER001.
+    if ($title_id && $user_token) {
+        if ($user_token eq 'USER001' && $title_id eq '002') {
+            return {};
+        } else {
+            return { error_msg => 'Unable to cancel hold' };
+        }
+    } else {
+        return undef;
+    }
+}
+
+sub suspend_hold {
+}
+
+sub get_patron_checkouts {
+    my $self = shift;
+
+    # Patron ID or patron auth token.
+    my $user_token = shift;
+
+    # Return an array of hashrefs representing checkouts;
+    # each hashref should have the following keys:
+    #   - xact_id: unique ID for this transaction (if used by API)
+    #   - title_id: unique ID for this title
+    #   - due_date
+    #   - download_url
+    #   - title: title of item, formatted for display
+    #   - author: author of item, formatted for display
+
+    my $checkouts = [];
+    # USER001 is our only valid user, so we only return checkouts for them.
+    if ($user_token eq 'USER001') {
+        push @$checkouts, {
+            xact_id => '1',
+            title_id => '001',
+            due_date => DateTime->today()->add( days => 7 )->iso8601(),
+            download_url => 'http://example.com/ebookapi/t/001/download',
+            title => 'The Fellowship of the Ring',
+            author => 'J. R. R. Tolkien'
+        };
+    }
+    $self->{checkouts} = $checkouts;
+    return $self->{checkouts};
+}
+
+sub get_patron_holds {
+    my $self = shift;
+
+    # Patron ID or patron auth token.
+    my $user_token = shift;
+
+    # Return an array of hashrefs representing holds;
+    # each hashref should have the following keys:
+    #   - title_id: unique ID for this title
+    #   - queue_position: this user's position in hold queue for this title
+    #   - queue_size: total number of holds on this title
+    #   - is_ready: whether hold is currently available for checkout
+    #   - is_frozen: whether hold is suspended
+    #   - thaw_date: when hold suspension expires (if suspended)
+    #   - create_date: when the hold was placed
+    #   - expire_date: when the hold expires
+    #   - title: title of item, formatted for display
+    #   - author: author of item, formatted for display
+
+    my $holds = [];
+    # USER001 is our only valid user, so we only return checkouts for them.
+    if ($user_token eq 'USER001') {
+        push @$holds, {
+            title_id => '002',
+            queue_position => 1,
+            queue_size => 1,
+            is_ready => 0,
+            is_frozen => 0,
+            create_date => DateTime->today()->subtract( days => 10 )->iso8601(),
+            expire_date => DateTime->today()->add( days => 60 )->iso8601(),
+            title => 'The Two Towers',
+            author => 'J. R. R. Tolkien'
+        };
+    }
+    $self->{holds} = $holds;
+    return $self->{holds};
+}
+
diff --git a/Open-ILS/src/perlmods/live_t/20-lp1541559-ebook-api.t b/Open-ILS/src/perlmods/live_t/20-lp1541559-ebook-api.t
new file mode 100644 (file)
index 0000000..72054a5
--- /dev/null
@@ -0,0 +1,170 @@
+#!perl
+use strict; use warnings;
+use Test::More tests => 21; # XXX
+use OpenILS::Utils::TestUtils;
+
+diag("Tests Ebook API");
+
+# ------------------------------------------------------------ 
+# 1. Set up test environment.
+# ------------------------------------------------------------ 
+
+use constant EBOOK_API_VENDOR => 'ebook_test';
+use constant EBOOK_API_OU => 1;
+
+# Title IDs:
+# 001 - checked out to test user
+# 002 - not available (checked out to another user)
+# 003 - available
+# 004 - not found (invalid/does not exist in external system)
+
+# Patrons.
+use constant EBOOK_API_PATRON_USERNAME  => '99999359616';
+use constant EBOOK_API_PATRON_PASSWORD  => 'andreac1234';
+use constant EBOOK_API_PATRON_NOT_FOUND => 'patron-not-found';
+
+my $script = OpenILS::Utils::TestUtils->new();
+$script->bootstrap;
+
+my $ebook_api = $script->session('open-ils.ebook_api');
+
+# ------------------------------------------------------------ 
+# 2. Sessions.
+# ------------------------------------------------------------ 
+
+# Initiate a new EbookAPI session and get a session ID.
+# Returns undef unless a new session was created.
+my $session_id_req = $ebook_api->request(
+    'open-ils.ebook_api.start_session', EBOOK_API_VENDOR, EBOOK_API_OU);
+my $session_id = $session_id_req->recv->content;
+ok($session_id, 'Initiated an EbookAPI session');
+
+# Check that an EbookAPI session exists matching our session ID.
+my $ck_session_id_req = $ebook_api->request(
+       'open-ils.ebook_api.check_session', $session_id, EBOOK_API_VENDOR, EBOOK_API_OU);
+my $ck_session_id = $ck_session_id_req->recv->content;
+ok($ck_session_id eq $session_id, 'Validated existing EbookAPI session');
+
+# Given an invalid or expired session ID, fallback to initiating 
+# a new EbookAPI session, which gives us a new session ID.
+# Returns undef unless a new session was created.
+my $new_session_id_req = $ebook_api->request(
+    'open-ils.ebook_api.check_session', '', EBOOK_API_VENDOR, EBOOK_API_OU);
+my $new_session_id = $new_session_id_req->recv->content;
+ok($new_session_id, 'Initiated new EbookAPI session when valid session ID not provided');
+
+# ------------------------------------------------------------ 
+# 3. Title availability and holdings.
+# ------------------------------------------------------------ 
+
+# Title is not available.
+my $title_001_avail_req = $ebook_api->request(
+    'open-ils.ebook_api.title.availability', $session_id, '001');
+my $title_001_avail = $title_001_avail_req->recv->content;
+is($title_001_avail, 0, 'Availability check 1/3 (not available)');
+
+# Title is available.
+my $title_003_avail_req = $ebook_api->request(
+    'open-ils.ebook_api.title.availability', $session_id, '003');
+my $title_003_avail = $title_003_avail_req->recv->content;
+is($title_003_avail, 1, 'Availability check 2/3 (available)');
+
+# Title is not found (availability lookup returns undef).
+my $title_004_avail_req = $ebook_api->request(
+    'open-ils.ebook_api.title.availability', $session_id, '004');
+my $title_004_avail = (defined $title_004_avail_req && defined $title_004_avail_req->recv) ? $title_004_avail_req->recv->content : undef;
+is($title_004_avail, undef, 'Availability check 3/3 (not found)');
+
+# Title has holdings, none available.
+my $title_001_holdings_req = $ebook_api->request(
+    'open-ils.ebook_api.title.holdings', $session_id, '001');
+my $title_001_holdings = $title_001_holdings_req->recv->content;
+ok(ref($title_001_holdings) && $title_001_holdings->{copies_owned} == 1 && $title_001_holdings->{copies_available} == 0 && $title_001_holdings->{formats}->[0] eq 'ebook', 'Holdings check 1/3 (1 owned, 0 available)');
+
+# Title has holdings, one copy available.
+my $title_003_holdings_req = $ebook_api->request(
+    'open-ils.ebook_api.title.holdings', $session_id, '003');
+my $title_003_holdings = $title_003_holdings_req->recv->content;
+ok(ref($title_003_holdings) && $title_003_holdings->{copies_owned} == 1 && $title_003_holdings->{copies_available} == 1 && $title_003_holdings->{formats}->[0] eq 'ebook', 'Holdings check 2/3 (1 owned, 1 available)');
+
+# Title not found, no holdings.
+my $title_004_holdings_req = $ebook_api->request(
+    'open-ils.ebook_api.title.holdings', $session_id, '004');
+my $title_004_holdings = $title_004_holdings_req->recv->content;
+ok(ref($title_004_holdings) && $title_004_holdings->{copies_owned} == 0 && $title_004_holdings->{copies_available} == 0 && scalar(@{$title_004_holdings->{formats}}) == 0, 'Holdings check 3/3 (0 owned, 0 available)');
+
+# ------------------------------------------------------------ 
+# 4. Patron authentication and caching.
+# ------------------------------------------------------------ 
+
+# Authenticate our test patron.
+$script->authenticate({
+        username => EBOOK_API_PATRON_USERNAME,
+        password => EBOOK_API_PATRON_PASSWORD,
+        type => 'opac'
+    });
+ok($script->authtoken, 'Have an authtoken');
+my $authtoken = $script->authtoken;
+
+# open-ils.ebook_api.patron.cache_password
+my $updated_cache_id_req = $ebook_api->request(
+    'open-ils.ebook_api.patron.cache_password', $session_id, EBOOK_API_PATRON_PASSWORD);
+my $updated_cache_id = $updated_cache_id_req->recv->content;
+ok($updated_cache_id eq $session_id, 'Session cache was updated with patron password');
+
+# ------------------------------------------------------------ 
+# 5. Patron transactions.
+# ------------------------------------------------------------ 
+
+# open-ils.ebook_api.patron.get_checkouts
+my $checkouts_req = $ebook_api->request(
+    'open-ils.ebook_api.patron.get_checkouts', $authtoken, $session_id, EBOOK_API_PATRON_USERNAME);
+my $checkouts = $checkouts_req->recv->content;
+ok(ref($checkouts) && defined $checkouts->[0]->{title_id}, 'Retrieved ebook checkouts for patron');
+
+# open-ils.ebook_api.patron.get_holds
+my $holds_req = $ebook_api->request(
+    'open-ils.ebook_api.patron.get_holds', $authtoken, $session_id, EBOOK_API_PATRON_USERNAME);
+my $holds = $holds_req->recv->content;
+ok(ref($holds) && defined $holds->[0]->{title_id}, 'Retrieved ebook holds for patron');
+
+# open-ils.ebook_api.patron.get_transactions
+my $xacts_req = $ebook_api->request(
+    'open-ils.ebook_api.patron.get_transactions', $authtoken, $session_id, EBOOK_API_PATRON_USERNAME);
+my $xacts = $xacts_req->recv->content;
+ok(ref($xacts) && exists $xacts->{checkouts} && exists $xacts->{holds}, 'Retrieved transactions for patron');
+ok(defined $xacts->{checkouts}->[0]->{title_id}, 'Retrieved transactions include checkouts');
+ok(defined $xacts->{holds}->[0]->{title_id}, 'Retrieved transactions include holds');
+
+# open-ils.ebook_api.checkout
+my $checkout_req = $ebook_api->request(
+    'open-ils.ebook_api.checkout', $authtoken, $session_id, '003', EBOOK_API_PATRON_USERNAME);
+my $checkout = $checkout_req->recv->content;
+ok(exists $checkout->{due_date}, 'Ebook checked out');
+
+# open-ils.ebook_api.renew
+my $renew_req = $ebook_api->request(
+    'open-ils.ebook_api.renew', $authtoken, $session_id, '001', EBOOK_API_PATRON_USERNAME);
+my $renew = $renew_req->recv->content;
+ok(exists $renew->{due_date}, 'Ebook renewed');
+
+# open-ils.ebook_api.checkin
+my $checkin_req = $ebook_api->request(
+    'open-ils.ebook_api.checkin', $authtoken, $session_id, '003', EBOOK_API_PATRON_USERNAME);
+my $checkin = $checkin_req->recv->content;
+ok(ref($checkin) && !exists $checkin->{error_msg}, 'Ebook checked in');
+
+# open-ils.ebook_api.cancel_hold
+my $cancel_hold_req = $ebook_api->request(
+    'open-ils.ebook_api.cancel_hold', $authtoken, $session_id, '002', EBOOK_API_PATRON_USERNAME);
+my $cancel_hold = $cancel_hold_req->recv->content;
+ok(ref($cancel_hold) && !exists $checkin->{error_msg}, 'Ebook hold canceled');
+
+# open-ils.ebook_api.place_hold
+my $place_hold_req = $ebook_api->request(
+    'open-ils.ebook_api.place_hold', $authtoken, $session_id, '002', EBOOK_API_PATRON_USERNAME);
+my $place_hold = $place_hold_req->recv->content;
+ok(exists $place_hold->{expire_date}, 'Ebook hold placed');
+
+# TODO: suspend hold
+
diff --git a/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t b/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t
new file mode 100644 (file)
index 0000000..338f043
--- /dev/null
@@ -0,0 +1,9 @@
+#!perl -T
+
+use Test::More tests => 2;
+
+BEGIN {
+    use_ok( 'OpenILS::Application::EbookAPI' );
+    use_ok( 'OpenILS::Application::EbookAPI::Test' );
+}
+