示例#1
0
class TestOPDSForDistributorsAPI(DatabaseTest):
    def setup(self):
        super(TestOPDSForDistributorsAPI, self).setup()
        self.collection = MockOPDSForDistributorsAPI.mock_collection(self._db)
        self.api = MockOPDSForDistributorsAPI(self._db, self.collection)

    def test__run_self_tests(self):
        """The self-test for OPDSForDistributorsAPI just tries to negotiate
        a fulfillment token.
        """
        class Mock(OPDSForDistributorsAPI):
            def __init__(self):
                pass

            def _get_token(self, _db):
                self.called_with = _db
                return "a token"

        api = Mock()
        [result] = api._run_self_tests(self._db)
        eq_(self._db, api.called_with)
        eq_("Negotiate a fulfillment token", result.name)
        eq_(True, result.success)
        eq_("a token", result.result)

    def test_can_fulfill_without_loan(self):
        """A book made available through OPDS For Distributors can be
        fulfilled with no underlying loan, if its delivery mechanism
        uses bearer token fulfillment.
        """
        patron = object()
        pool = self._licensepool(edition=None, collection=self.collection)
        [lpdm] = pool.delivery_mechanisms

        m = self.api.can_fulfill_without_loan

        # No LicensePoolDeliveryMechanism -> False
        eq_(False, m(patron, pool, None))

        # No LicensePool -> False (there can be multiple LicensePools for
        # a single LicensePoolDeliveryMechanism).
        eq_(False, m(patron, None, lpdm))

        # No DeliveryMechanism -> False
        old_dm = lpdm.delivery_mechanism
        lpdm.delivery_mechanism = None
        eq_(False, m(patron, pool, lpdm))

        # DRM mechanism requires identifying a specific patron -> False
        lpdm.delivery_mechanism = old_dm
        lpdm.delivery_mechanism.drm_scheme = DeliveryMechanism.ADOBE_DRM
        eq_(False, m(patron, pool, lpdm))

        # Otherwise -> True
        lpdm.delivery_mechanism.drm_scheme = DeliveryMechanism.NO_DRM
        eq_(True, m(patron, pool, lpdm))

        lpdm.delivery_mechanism.drm_scheme = DeliveryMechanism.BEARER_TOKEN
        eq_(True, m(patron, pool, lpdm))

    def test_get_token_success(self):
        # The API hasn't been used yet, so it will need to find the auth
        # document and authenticate url.
        feed = '<feed><link rel="http://opds-spec.org/auth/document" href="http://authdoc"/></feed>'
        self.api.queue_response(200, content=feed)
        auth_doc = json.dumps({
            "authentication": [{
                "type":
                "http://opds-spec.org/auth/oauth/client_credentials",
                "links": [{
                    "rel": "authenticate",
                    "href": "http://authenticate",
                }]
            }]
        })
        self.api.queue_response(200, content=auth_doc)
        token = self._str
        token_response = json.dumps({"access_token": token, "expires_in": 60})
        self.api.queue_response(200, content=token_response)

        eq_(token, self.api._get_token(self._db).credential)

        # Now that the API has the authenticate url, it only needs
        # to get the token.
        self.api.queue_response(200, content=token_response)
        eq_(token, self.api._get_token(self._db).credential)

        # A credential was created.
        [credential] = self._db.query(Credential).all()
        eq_(token, credential.credential)

        # If we call _get_token again, it uses the existing credential.
        eq_(token, self.api._get_token(self._db).credential)

        self._db.delete(credential)

        # Create a new API that doesn't have an auth url yet.
        self.api = MockOPDSForDistributorsAPI(self._db, self.collection)

        # This feed requires authentication and returns the auth document.
        auth_doc = json.dumps({
            "authentication": [{
                "type":
                "http://opds-spec.org/auth/oauth/client_credentials",
                "links": [{
                    "rel": "authenticate",
                    "href": "http://authenticate",
                }]
            }]
        })
        self.api.queue_response(401, content=auth_doc)
        token = self._str
        token_response = json.dumps({"access_token": token, "expires_in": 60})
        self.api.queue_response(200, content=token_response)

        eq_(token, self.api._get_token(self._db).credential)

    def test_get_token_errors(self):
        no_auth_document = '<feed></feed>'
        self.api.queue_response(200, content=no_auth_document)
        assert_raises_regexp(
            LibraryAuthorizationFailedException,
            "No authentication document link found in http://opds",
            self.api._get_token, self._db)

        feed = '<feed><link rel="http://opds-spec.org/auth/document" href="http://authdoc"/></feed>'
        self.api.queue_response(200, content=feed)
        auth_doc_without_client_credentials = json.dumps(
            {"authentication": []})
        self.api.queue_response(200,
                                content=auth_doc_without_client_credentials)
        assert_raises_regexp(
            LibraryAuthorizationFailedException,
            "Could not find any credential-based authentication mechanisms in http://authdoc",
            self.api._get_token, self._db)

        self.api.queue_response(200, content=feed)
        auth_doc_without_links = json.dumps({
            "authentication": [{
                "type":
                "http://opds-spec.org/auth/oauth/client_credentials",
            }]
        })
        self.api.queue_response(200, content=auth_doc_without_links)
        assert_raises_regexp(
            LibraryAuthorizationFailedException,
            "Could not find any authentication links in http://authdoc",
            self.api._get_token, self._db)

        self.api.queue_response(200, content=feed)
        auth_doc = json.dumps({
            "authentication": [{
                "type":
                "http://opds-spec.org/auth/oauth/client_credentials",
                "links": [{
                    "rel": "authenticate",
                    "href": "http://authenticate",
                }]
            }]
        })
        self.api.queue_response(200, content=auth_doc)
        token_response = json.dumps({"error": "unexpected error"})
        self.api.queue_response(200, content=token_response)
        assert_raises_regexp(
            LibraryAuthorizationFailedException,
            "Document retrieved from http://authenticate is not a bearer token: {.*unexpected error.*}",
            self.api._get_token, self._db)

    def test_checkin(self):
        # The patron has two loans, one from this API's collection and
        # one from a different collection.
        patron = self._patron()

        data_source = DataSource.lookup(self._db,
                                        "Biblioboard",
                                        autocreate=True)
        edition, pool = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )
        pool.loan_to(patron)

        other_collection = self._collection(
            protocol=ExternalIntegration.OVERDRIVE)
        other_edition, other_pool = self._edition(
            identifier_type=Identifier.OVERDRIVE_ID,
            data_source_name=DataSource.OVERDRIVE,
            with_license_pool=True,
            collection=other_collection,
        )
        other_pool.loan_to(patron)

        eq_(2, self._db.query(Loan).count())

        self.api.checkin(patron, "1234", pool)

        # The loan from this API's collection has been deleted.
        # The loan from the other collection wasn't touched.
        eq_(1, self._db.query(Loan).count())
        [loan] = self._db.query(Loan).all()
        eq_(other_pool, loan.license_pool)

    def test_checkout(self):
        patron = self._patron()

        data_source = DataSource.lookup(self._db,
                                        "Biblioboard",
                                        autocreate=True)
        edition, pool = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )

        loan_info = self.api.checkout(patron, "1234", pool,
                                      Representation.EPUB_MEDIA_TYPE)
        eq_(self.collection.id, loan_info.collection_id)
        eq_(data_source.name, loan_info.data_source_name)
        eq_(Identifier.URI, loan_info.identifier_type)
        eq_(pool.identifier.identifier, loan_info.identifier)

        # The loan's start date has been set to the current time.
        now = datetime.datetime.utcnow()
        assert (now - loan_info.start_date).seconds < 2

        # The loan is of indefinite duration.
        eq_(None, loan_info.end_date)

    def test_fulfill(self):
        patron = self._patron()

        data_source = DataSource.lookup(self._db,
                                        "Biblioboard",
                                        autocreate=True)
        edition, pool = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )
        # This pool doesn't have an acquisition link, so
        # we can't fulfill it yet.
        assert_raises(CannotFulfill, self.api.fulfill, patron, "1234", pool,
                      Representation.EPUB_MEDIA_TYPE)

        # Set up an epub acquisition link for the pool.
        url = self._url
        link, ignore = pool.identifier.add_link(
            Hyperlink.GENERIC_OPDS_ACQUISITION,
            url,
            data_source,
            Representation.EPUB_MEDIA_TYPE,
        )
        pool.set_delivery_mechanism(
            Representation.EPUB_MEDIA_TYPE,
            DeliveryMechanism.NO_DRM,
            RightsStatus.IN_COPYRIGHT,
            link.resource,
        )

        # Set the API's auth url so it doesn't have to get it -
        # that's tested in test_get_token.
        self.api.auth_url = "http://auth"

        token_response = json.dumps({
            "access_token": "token",
            "expires_in": 60
        })
        self.api.queue_response(200, content=token_response)

        fulfillment_time = datetime.datetime.utcnow()
        fulfillment_info = self.api.fulfill(patron, "1234", pool,
                                            Representation.EPUB_MEDIA_TYPE)
        eq_(self.collection.id, fulfillment_info.collection_id)
        eq_(data_source.name, fulfillment_info.data_source_name)
        eq_(Identifier.URI, fulfillment_info.identifier_type)
        eq_(pool.identifier.identifier, fulfillment_info.identifier)
        eq_(None, fulfillment_info.content_link)

        eq_(DeliveryMechanism.BEARER_TOKEN, fulfillment_info.content_type)
        bearer_token_document = json.loads(fulfillment_info.content)
        expires_in = bearer_token_document['expires_in']
        assert expires_in < 60
        eq_("Bearer", bearer_token_document['token_type'])
        eq_("token", bearer_token_document['access_token'])
        eq_(url, bearer_token_document['location'])

        # The FulfillmentInfo's content_expires is approximately the
        # time you get if you add the number of seconds until the
        # bearer token expires to the time at which the title was
        # originally fulfilled.
        expect_expiration = fulfillment_time + datetime.timedelta(
            seconds=expires_in)
        assert abs((fulfillment_info.content_expires -
                    expect_expiration).total_seconds()) < 5

    def test_patron_activity(self):
        # The patron has two loans from this API's collection and
        # one from a different collection.
        patron = self._patron()

        data_source = DataSource.lookup(self._db,
                                        "Biblioboard",
                                        autocreate=True)
        e1, p1 = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )
        p1.loan_to(patron)

        e2, p2 = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )
        p2.loan_to(patron)

        other_collection = self._collection(
            protocol=ExternalIntegration.OVERDRIVE)
        e3, p3 = self._edition(
            identifier_type=Identifier.OVERDRIVE_ID,
            data_source_name=DataSource.OVERDRIVE,
            with_license_pool=True,
            collection=other_collection,
        )
        p3.loan_to(patron)

        activity = self.api.patron_activity(patron, "1234")
        eq_(2, len(activity))
        [l1, l2] = activity
        eq_(l1.collection_id, self.collection.id)
        eq_(l2.collection_id, self.collection.id)
        eq_(set([l1.identifier, l2.identifier]),
            set([p1.identifier.identifier, p2.identifier.identifier]))
示例#2
0
class TestOPDSForDistributorsAPI(DatabaseTest):
    def setup(self):
        super(TestOPDSForDistributorsAPI, self).setup()
        self.collection = MockOPDSForDistributorsAPI.mock_collection(self._db)
        self.api = MockOPDSForDistributorsAPI(self._db, self.collection)

    def test_get_token_success(self):
        # The API hasn't been used yet, so it will need to find the auth
        # document and authenticate url.
        feed = '<feed><link rel="http://opds-spec.org/auth/document" href="http://authdoc"/></feed>'
        self.api.queue_response(200, content=feed)
        auth_doc = json.dumps({
            "authentication": [{
                "type":
                "http://opds-spec.org/auth/oauth/client_credentials",
                "links": [{
                    "rel": "authenticate",
                    "href": "http://authenticate",
                }]
            }]
        })
        self.api.queue_response(200, content=auth_doc)
        token = self._str
        token_response = json.dumps({"access_token": token, "expires_in": 60})
        self.api.queue_response(200, content=token_response)

        eq_(token, self.api._get_token(self._db).credential)

        # Now that the API has the authenticate url, it only needs
        # to get the token.
        self.api.queue_response(200, content=token_response)
        eq_(token, self.api._get_token(self._db).credential)

        # A credential was created.
        [credential] = self._db.query(Credential).all()
        eq_(token, credential.credential)

        # If we call _get_token again, it uses the existing credential.
        eq_(token, self.api._get_token(self._db).credential)

        self._db.delete(credential)

        # Create a new API that doesn't have an auth url yet.
        self.api = MockOPDSForDistributorsAPI(self._db, self.collection)

        # This feed requires authentication and returns the auth document.
        auth_doc = json.dumps({
            "authentication": [{
                "type":
                "http://opds-spec.org/auth/oauth/client_credentials",
                "links": [{
                    "rel": "authenticate",
                    "href": "http://authenticate",
                }]
            }]
        })
        self.api.queue_response(401, content=auth_doc)
        token = self._str
        token_response = json.dumps({"access_token": token, "expires_in": 60})
        self.api.queue_response(200, content=token_response)

        eq_(token, self.api._get_token(self._db).credential)

    def test_get_token_errors(self):
        no_auth_document = '<feed></feed>'
        self.api.queue_response(200, content=no_auth_document)
        assert_raises(LibraryAuthorizationFailedException, self.api._get_token,
                      self._db)

        feed = '<feed><link rel="http://opds-spec.org/auth/document" href="http://authdoc"/></feed>'
        self.api.queue_response(200, content=feed)
        auth_doc_without_client_credentials = json.dumps(
            {"authentication": []})
        self.api.queue_response(200,
                                content=auth_doc_without_client_credentials)
        assert_raises(LibraryAuthorizationFailedException, self.api._get_token,
                      self._db)

        self.api.queue_response(200, content=feed)
        auth_doc_without_links = json.dumps({
            "authentication": [{
                "type":
                "http://opds-spec.org/auth/oauth/client_credentials",
            }]
        })
        self.api.queue_response(200, content=auth_doc_without_links)
        assert_raises(LibraryAuthorizationFailedException, self.api._get_token,
                      self._db)

        self.api.queue_response(200, content=feed)
        auth_doc = json.dumps({
            "authentication": [{
                "type":
                "http://opds-spec.org/auth/oauth/client_credentials",
                "links": [{
                    "rel": "authenticate",
                    "href": "http://authenticate",
                }]
            }]
        })
        self.api.queue_response(200, content=auth_doc)
        token_response = json.dumps({"error": "unexpected error"})
        self.api.queue_response(200, content=token_response)
        assert_raises(LibraryAuthorizationFailedException, self.api._get_token,
                      self._db)

    def test_checkin(self):
        # The patron has two loans, one from this API's collection and
        # one from a different collection.
        patron = self._patron()

        data_source = DataSource.lookup(self._db,
                                        "Biblioboard",
                                        autocreate=True)
        edition, pool = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )
        pool.loan_to(patron)

        other_collection = self._collection(
            protocol=ExternalIntegration.OVERDRIVE)
        other_edition, other_pool = self._edition(
            identifier_type=Identifier.OVERDRIVE_ID,
            data_source_name=DataSource.OVERDRIVE,
            with_license_pool=True,
            collection=other_collection,
        )
        other_pool.loan_to(patron)

        eq_(2, self._db.query(Loan).count())

        self.api.checkin(patron, "1234", pool)

        # The loan from this API's collection has been deleted.
        # The loan from the other collection wasn't touched.
        eq_(1, self._db.query(Loan).count())
        [loan] = self._db.query(Loan).all()
        eq_(other_pool, loan.license_pool)

    def test_checkout(self):
        patron = self._patron()

        data_source = DataSource.lookup(self._db,
                                        "Biblioboard",
                                        autocreate=True)
        edition, pool = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )

        loan_info = self.api.checkout(patron, "1234", pool,
                                      Representation.EPUB_MEDIA_TYPE)
        eq_(self.collection.id, loan_info.collection_id)
        eq_(data_source.name, loan_info.data_source_name)
        eq_(Identifier.URI, loan_info.identifier_type)
        eq_(pool.identifier.identifier, loan_info.identifier)
        eq_(None, loan_info.end_date)

    def test_fulfill(self):
        patron = self._patron()

        data_source = DataSource.lookup(self._db,
                                        "Biblioboard",
                                        autocreate=True)
        edition, pool = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )
        # This pool doesn't have an acquisition link, so
        # we can't fulfill it yet.
        assert_raises(CannotFulfill, self.api.fulfill, patron, "1234", pool,
                      Representation.EPUB_MEDIA_TYPE)

        # Set up an epub acquisition link for the pool.
        url = self._url
        link, ignore = pool.identifier.add_link(
            Hyperlink.GENERIC_OPDS_ACQUISITION,
            url,
            data_source,
            Representation.EPUB_MEDIA_TYPE,
        )
        pool.set_delivery_mechanism(
            Representation.EPUB_MEDIA_TYPE,
            DeliveryMechanism.NO_DRM,
            RightsStatus.IN_COPYRIGHT,
            link.resource,
        )

        # Set the API's auth url so it doesn't have to get it -
        # that's tested in test_get_token.
        self.api.auth_url = "http://auth"

        token_response = json.dumps({
            "access_token": "token",
            "expires_in": 60
        })
        self.api.queue_response(200, content=token_response)

        fulfillment_time = datetime.datetime.utcnow()
        fulfillment_info = self.api.fulfill(patron, "1234", pool,
                                            Representation.EPUB_MEDIA_TYPE)
        eq_(self.collection.id, fulfillment_info.collection_id)
        eq_(data_source.name, fulfillment_info.data_source_name)
        eq_(Identifier.URI, fulfillment_info.identifier_type)
        eq_(pool.identifier.identifier, fulfillment_info.identifier)
        eq_(None, fulfillment_info.content_link)

        eq_(DeliveryMechanism.BEARER_TOKEN, fulfillment_info.content_type)
        bearer_token_document = json.loads(fulfillment_info.content)
        expires_in = bearer_token_document['expires_in']
        assert expires_in < 60
        eq_("Bearer", bearer_token_document['token_type'])
        eq_("token", bearer_token_document['access_token'])
        eq_(url, bearer_token_document['location'])

        # The FulfillmentInfo's content_expires is approximately the
        # time you get if you add the number of seconds until the
        # bearer token expires to the time at which the title was
        # originally fulfilled.
        expect_expiration = fulfillment_time + datetime.timedelta(
            seconds=expires_in)
        assert abs((fulfillment_info.content_expires -
                    expect_expiration).total_seconds()) < 5

    def test_patron_activity(self):
        # The patron has two loans from this API's collection and
        # one from a different collection.
        patron = self._patron()

        data_source = DataSource.lookup(self._db,
                                        "Biblioboard",
                                        autocreate=True)
        e1, p1 = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )
        p1.loan_to(patron)

        e2, p2 = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )
        p2.loan_to(patron)

        other_collection = self._collection(
            protocol=ExternalIntegration.OVERDRIVE)
        e3, p3 = self._edition(
            identifier_type=Identifier.OVERDRIVE_ID,
            data_source_name=DataSource.OVERDRIVE,
            with_license_pool=True,
            collection=other_collection,
        )
        p3.loan_to(patron)

        activity = self.api.patron_activity(patron, "1234")
        eq_(2, len(activity))
        [l1, l2] = activity
        eq_(l1.collection_id, self.collection.id)
        eq_(l2.collection_id, self.collection.id)
        eq_(set([l1.identifier, l2.identifier]),
            set([p1.identifier.identifier, p2.identifier.identifier]))
示例#3
0
class TestOPDSForDistributorsAPI(DatabaseTest):
    def setup_method(self):
        super(TestOPDSForDistributorsAPI, self).setup_method()
        self.collection = MockOPDSForDistributorsAPI.mock_collection(self._db)
        self.api = MockOPDSForDistributorsAPI(self._db, self.collection)

    def test_external_integration(self):
        assert self.collection.external_integration == self.api.external_integration(
            self._db)

    def test__run_self_tests(self):
        """The self-test for OPDSForDistributorsAPI just tries to negotiate
        a fulfillment token.
        """
        class Mock(OPDSForDistributorsAPI):
            def __init__(self):
                pass

            def _get_token(self, _db):
                self.called_with = _db
                return "a token"

        api = Mock()
        [result] = api._run_self_tests(self._db)
        assert self._db == api.called_with
        assert "Negotiate a fulfillment token" == result.name
        assert True == result.success
        assert "a token" == result.result

    def test_supported_media_types(self):
        # If the default client supports media type X with the
        # BEARER_TOKEN access control scheme, then X is a supported
        # media type for an OPDS For Distributors collection.
        supported = self.api.SUPPORTED_MEDIA_TYPES
        for (format,
             drm) in DeliveryMechanism.default_client_can_fulfill_lookup:
            if drm == (DeliveryMechanism.BEARER_TOKEN) and format is not None:
                assert format in supported

        # Here's a media type that sometimes shows up in OPDS For
        # Distributors collections but is _not_ supported. Incoming
        # items with this media type will _not_ be imported.
        assert MediaTypes.JPEG_MEDIA_TYPE not in supported

    def test_can_fulfill_without_loan(self):
        """A book made available through OPDS For Distributors can be
        fulfilled with no underlying loan, if its delivery mechanism
        uses bearer token fulfillment.
        """
        patron = object()
        pool = self._licensepool(edition=None, collection=self.collection)
        [lpdm] = pool.delivery_mechanisms

        m = self.api.can_fulfill_without_loan

        # No LicensePoolDeliveryMechanism -> False
        assert False == m(patron, pool, None)

        # No LicensePool -> False (there can be multiple LicensePools for
        # a single LicensePoolDeliveryMechanism).
        assert False == m(patron, None, lpdm)

        # No DeliveryMechanism -> False
        old_dm = lpdm.delivery_mechanism
        lpdm.delivery_mechanism = None
        assert False == m(patron, pool, lpdm)

        # DRM mechanism requires identifying a specific patron -> False
        lpdm.delivery_mechanism = old_dm
        lpdm.delivery_mechanism.drm_scheme = DeliveryMechanism.ADOBE_DRM
        assert False == m(patron, pool, lpdm)

        # Otherwise -> True
        lpdm.delivery_mechanism.drm_scheme = DeliveryMechanism.NO_DRM
        assert True == m(patron, pool, lpdm)

        lpdm.delivery_mechanism.drm_scheme = DeliveryMechanism.BEARER_TOKEN
        assert True == m(patron, pool, lpdm)

    def test_get_token_success(self):
        # The API hasn't been used yet, so it will need to find the auth
        # document and authenticate url.
        feed = '<feed><link rel="http://opds-spec.org/auth/document" href="http://authdoc"/></feed>'
        self.api.queue_response(200, content=feed)
        auth_doc = json.dumps({
            "authentication": [{
                "type":
                "http://opds-spec.org/auth/oauth/client_credentials",
                "links": [{
                    "rel": "authenticate",
                    "href": "http://authenticate",
                }],
            }]
        })
        self.api.queue_response(200, content=auth_doc)
        token = self._str
        token_response = json.dumps({"access_token": token, "expires_in": 60})
        self.api.queue_response(200, content=token_response)

        assert token == self.api._get_token(self._db).credential

        # Now that the API has the authenticate url, it only needs
        # to get the token.
        self.api.queue_response(200, content=token_response)
        assert token == self.api._get_token(self._db).credential

        # A credential was created.
        [credential] = self._db.query(Credential).all()
        assert token == credential.credential

        # If we call _get_token again, it uses the existing credential.
        assert token == self.api._get_token(self._db).credential

        self._db.delete(credential)

        # Create a new API that doesn't have an auth url yet.
        self.api = MockOPDSForDistributorsAPI(self._db, self.collection)

        # This feed requires authentication and returns the auth document.
        auth_doc = json.dumps({
            "authentication": [{
                "type":
                "http://opds-spec.org/auth/oauth/client_credentials",
                "links": [{
                    "rel": "authenticate",
                    "href": "http://authenticate",
                }],
            }]
        })
        self.api.queue_response(401, content=auth_doc)
        token = self._str
        token_response = json.dumps({"access_token": token, "expires_in": 60})
        self.api.queue_response(200, content=token_response)

        assert token == self.api._get_token(self._db).credential

    def test_get_token_errors(self):
        no_auth_document = "<feed></feed>"
        self.api.queue_response(200, content=no_auth_document)
        with pytest.raises(LibraryAuthorizationFailedException) as excinfo:
            self.api._get_token(self._db)
        assert "No authentication document link found in http://opds" in str(
            excinfo.value)

        feed = '<feed><link rel="http://opds-spec.org/auth/document" href="http://authdoc"/></feed>'
        self.api.queue_response(200, content=feed)
        auth_doc_without_client_credentials = json.dumps(
            {"authentication": []})
        self.api.queue_response(200,
                                content=auth_doc_without_client_credentials)
        with pytest.raises(LibraryAuthorizationFailedException) as excinfo:
            self.api._get_token(self._db)
        assert (
            "Could not find any credential-based authentication mechanisms in http://authdoc"
            in str(excinfo.value))

        self.api.queue_response(200, content=feed)
        auth_doc_without_links = json.dumps({
            "authentication": [{
                "type":
                "http://opds-spec.org/auth/oauth/client_credentials",
            }]
        })
        self.api.queue_response(200, content=auth_doc_without_links)
        with pytest.raises(LibraryAuthorizationFailedException) as excinfo:
            self.api._get_token(self._db)
        assert "Could not find any authentication links in http://authdoc" in str(
            excinfo.value)

        self.api.queue_response(200, content=feed)
        auth_doc = json.dumps({
            "authentication": [{
                "type":
                "http://opds-spec.org/auth/oauth/client_credentials",
                "links": [{
                    "rel": "authenticate",
                    "href": "http://authenticate",
                }],
            }]
        })
        self.api.queue_response(200, content=auth_doc)
        token_response = json.dumps({"error": "unexpected error"})
        self.api.queue_response(200, content=token_response)
        with pytest.raises(LibraryAuthorizationFailedException) as excinfo:
            self.api._get_token(self._db)
        assert (
            'Document retrieved from http://authenticate is not a bearer token: {"error": "unexpected error"}'
            in str(excinfo.value))

    def test_checkin(self):
        # The patron has two loans, one from this API's collection and
        # one from a different collection.
        patron = self._patron()

        data_source = DataSource.lookup(self._db,
                                        "Biblioboard",
                                        autocreate=True)
        edition, pool = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )
        pool.loan_to(patron)

        other_collection = self._collection(
            protocol=ExternalIntegration.OVERDRIVE)
        other_edition, other_pool = self._edition(
            identifier_type=Identifier.OVERDRIVE_ID,
            data_source_name=DataSource.OVERDRIVE,
            with_license_pool=True,
            collection=other_collection,
        )
        other_pool.loan_to(patron)

        assert 2 == self._db.query(Loan).count()

        self.api.checkin(patron, "1234", pool)

        # The loan from this API's collection has been deleted.
        # The loan from the other collection wasn't touched.
        assert 1 == self._db.query(Loan).count()
        [loan] = self._db.query(Loan).all()
        assert other_pool == loan.license_pool

    def test_checkout(self):
        patron = self._patron()

        data_source = DataSource.lookup(self._db,
                                        "Biblioboard",
                                        autocreate=True)
        edition, pool = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )

        loan_info = self.api.checkout(patron, "1234", pool,
                                      Representation.EPUB_MEDIA_TYPE)
        assert self.collection.id == loan_info.collection_id
        assert data_source.name == loan_info.data_source_name
        assert Identifier.URI == loan_info.identifier_type
        assert pool.identifier.identifier == loan_info.identifier

        # The loan's start date has been set to the current time.
        now = utc_now()
        assert (now - loan_info.start_date).seconds < 2

        # The loan is of indefinite duration.
        assert None == loan_info.end_date

    def test_fulfill(self):
        patron = self._patron()

        data_source = DataSource.lookup(self._db,
                                        "Biblioboard",
                                        autocreate=True)
        edition, pool = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )
        # This pool doesn't have an acquisition link, so
        # we can't fulfill it yet.
        pytest.raises(
            CannotFulfill,
            self.api.fulfill,
            patron,
            "1234",
            pool,
            Representation.EPUB_MEDIA_TYPE,
        )

        # Set up an epub acquisition link for the pool.
        url = self._url
        link, ignore = pool.identifier.add_link(
            Hyperlink.GENERIC_OPDS_ACQUISITION,
            url,
            data_source,
            Representation.EPUB_MEDIA_TYPE,
        )
        pool.set_delivery_mechanism(
            Representation.EPUB_MEDIA_TYPE,
            DeliveryMechanism.NO_DRM,
            RightsStatus.IN_COPYRIGHT,
            link.resource,
        )

        # Set the API's auth url so it doesn't have to get it -
        # that's tested in test_get_token.
        self.api.auth_url = "http://auth"

        token_response = json.dumps({
            "access_token": "token",
            "expires_in": 60
        })
        self.api.queue_response(200, content=token_response)

        fulfillment_time = utc_now()
        fulfillment_info = self.api.fulfill(patron, "1234", pool,
                                            Representation.EPUB_MEDIA_TYPE)
        assert self.collection.id == fulfillment_info.collection_id
        assert data_source.name == fulfillment_info.data_source_name
        assert Identifier.URI == fulfillment_info.identifier_type
        assert pool.identifier.identifier == fulfillment_info.identifier
        assert None == fulfillment_info.content_link

        assert DeliveryMechanism.BEARER_TOKEN == fulfillment_info.content_type
        bearer_token_document = json.loads(fulfillment_info.content)
        expires_in = bearer_token_document["expires_in"]
        assert expires_in < 60
        assert "Bearer" == bearer_token_document["token_type"]
        assert "token" == bearer_token_document["access_token"]
        assert url == bearer_token_document["location"]

        # The FulfillmentInfo's content_expires is approximately the
        # time you get if you add the number of seconds until the
        # bearer token expires to the time at which the title was
        # originally fulfilled.
        expect_expiration = fulfillment_time + datetime.timedelta(
            seconds=expires_in)
        assert (abs((fulfillment_info.content_expires -
                     expect_expiration).total_seconds()) < 5)

    def test_patron_activity(self):
        # The patron has two loans from this API's collection and
        # one from a different collection.
        patron = self._patron()

        data_source = DataSource.lookup(self._db,
                                        "Biblioboard",
                                        autocreate=True)
        e1, p1 = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )
        p1.loan_to(patron)

        e2, p2 = self._edition(
            identifier_type=Identifier.URI,
            data_source_name=data_source.name,
            with_license_pool=True,
            collection=self.collection,
        )
        p2.loan_to(patron)

        other_collection = self._collection(
            protocol=ExternalIntegration.OVERDRIVE)
        e3, p3 = self._edition(
            identifier_type=Identifier.OVERDRIVE_ID,
            data_source_name=DataSource.OVERDRIVE,
            with_license_pool=True,
            collection=other_collection,
        )
        p3.loan_to(patron)

        activity = self.api.patron_activity(patron, "1234")
        assert 2 == len(activity)
        [l1, l2] = activity
        assert l1.collection_id == self.collection.id
        assert l2.collection_id == self.collection.id
        assert set([l1.identifier, l2.identifier]) == set(
            [p1.identifier.identifier, p2.identifier.identifier])