From f623e850a46232bb5e4566a73f475286e826e9c9 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Thu, 9 Sep 2021 14:28:31 -0300 Subject: [PATCH] Add tests for ODL with multiple licenses. --- tests/files/odl/multiple_license.opds | 141 +++++++++++++++ tests/files/odl2/multiple_license.json | 235 +++++++++++++++++++++++++ tests/test_odl.py | 153 +++++++++++++--- tests/test_odl2.py | 18 +- 4 files changed, 516 insertions(+), 31 deletions(-) create mode 100644 tests/files/odl/multiple_license.opds create mode 100644 tests/files/odl2/multiple_license.json diff --git a/tests/files/odl/multiple_license.opds b/tests/files/odl/multiple_license.opds new file mode 100644 index 0000000000..96716d0eaf --- /dev/null +++ b/tests/files/odl/multiple_license.opds @@ -0,0 +1,141 @@ + + + https://market.example.com/api/libraries/harvest.atom + Example + 2021-09-09T14:19:33Z + /favicon.ico + + Example + https://market.example.com + support@example.zendesk.com + + +481 +100 + + +The Murmur of Bees +https://www.example.com/item/4154969 +urn:ISBN:9781542090490 +urn:ISBN:9781542040501 +476 + + Sofía Segovia + https://example.com/store/browse/recent.atom?author_id=519449&lang=en + + + Simon Bruni + https://example.com/store/browse/recent.atom?contributor_id=1447607&lang=en + +2021-06-14T17:38:53Z +2021-07-09T13:10:07Z +en +Amazon Publishing +2019-04-16 + +From a beguiling voice in Mexican fiction comes an astonishing novel—her first to be translated into English—about a mysterious child with the power to change a family’s history in a country on the verge of revolution. + +From the day that old Nana Reja found a baby abandoned under a bridge, the life of a small Mexican town forever changed. Disfigured and covered in a blanket of bees, little Simonopio is for some locals the stuff of superstition, a child kissed by the devil. But he is welcomed by landowners Francisco and Beatriz Morales, who adopt him and care for him as if he were their own. As he grows up, Simonopio becomes a cause for wonder to the Morales family, because when the uncannily gifted child closes his eyes, he can see what no one else can—visions of all that’s yet to come, both beautiful and dangerous. Followed by his protective swarm of bees and living to deliver his adoptive family from threats—both human and those of nature—Simonopio’s purpose in Linares will, in time, be divined. + +Set against the backdrop of the Mexican Revolution and the devastating influenza of 1918, +The Murmur of Bees +captures both the fate of a country in flux and the destiny of one family that has put their love, faith, and future in the unbelievable. + +476 pages +5 MB + + + + + + + + + + + + urn:uuid:ed3c2fe4-d44d-427e-afde-cf246d21ecd9 + application/epub+zip + text/html + 2021-06-14T19:46:17+02:00 + + {{expires_1}} + 40 + 10 + 5097600 + + + application/vnd.adobe.adept+xml + 6 + true + false + false + + + application/vnd.readium.lcp.license.v1.0+json + 6 + true + false + false + + + + + + urn:uuid:c9079f86-3cdb-4144-b106-04bd70973586 + application/epub+zip + text/html + 2021-07-09T15:08:55+02:00 + + {{expires_2}} + 40 + 10 + 5097600 + + + application/vnd.adobe.adept+xml + 6 + true + false + false + + + application/vnd.readium.lcp.license.v1.0+json + 6 + true + false + false + + + + + + urn:uuid:49f6bb8d-1912-4b95-8e62-a65d7c8c02fd + application/epub+zip + text/html + 2021-07-09T15:08:55+02:00 + + {{expires_3}} + 40 + 10 + 5097600 + + + application/vnd.adobe.adept+xml + 6 + true + false + false + + + application/vnd.readium.lcp.license.v1.0+json + 6 + true + false + false + + + + + + diff --git a/tests/files/odl2/multiple_license.json b/tests/files/odl2/multiple_license.json new file mode 100644 index 0000000000..e76f62acbf --- /dev/null +++ b/tests/files/odl2/multiple_license.json @@ -0,0 +1,235 @@ +{ + "metadata": { + "title": "Test", + "itemsPerPage": 10, + "currentPage": 1, + "numberOfItems": 100 + }, + "links": [ + { + "type": "application/opds+json", + "rel": "self", + "href": "https://market.feedbooks.com/api/libraries/harvest.json" + } + ], + "publications": [ + { + "metadata": { + "@type": "http://schema.org/Book", + "title": "The Murmur of Bees", + "language": "en", + "modified": "2021-07-09T13:10:07Z", + "published": "2019-04-16T00:00:00Z", + "numberOfPages": 476, + "identifier": "urn:ISBN:9781542090490", + "schema:workExample": [ + { + "@type": "http://schema.org/Book", + "schema:bookFormat": "http://schema.org/Hardcover", + "schema:isbn": "urn:ISBN:9781542040501" + } + ], + "author": [ + { + "name": "Sofía Segovia", + "links": [ + { + "type": "application/opds+json", + "href": "https://example.com/store/browse/recent.json?author_id=519449&lang=en" + } + ] + } + ], + "translator": [ + { + "name": "Simon Bruni", + "links": [ + { + "type": "application/opds+json", + "href": "https://example.com/store/browse/recent.json?contributor_id=1447607&lang=en" + } + ] + } + ], + "publisher": { + "name": "Amazon Publishing", + "links": [ + { + "type": "application/opds+json", + "href": "https://example.com/store/browse/recent.json?lang=en&publisher=Amazon+Publishing" + } + ] + }, + "description": "

\nFrom a beguiling voice in Mexican fiction comes an astonishing novel—her first to be translated into English—about a mysterious child with the power to change a family’s history in a country on the verge of revolution.\n

\n

From the day that old Nana Reja found a baby abandoned under a bridge, the life of a small Mexican town forever changed. Disfigured and covered in a blanket of bees, little Simonopio is for some locals the stuff of superstition, a child kissed by the devil. But he is welcomed by landowners Francisco and Beatriz Morales, who adopt him and care for him as if he were their own. As he grows up, Simonopio becomes a cause for wonder to the Morales family, because when the uncannily gifted child closes his eyes, he can see what no one else can—visions of all that’s yet to come, both beautiful and dangerous. Followed by his protective swarm of bees and living to deliver his adoptive family from threats—both human and those of nature—Simonopio’s purpose in Linares will, in time, be divined.

\n

\nSet against the backdrop of the Mexican Revolution and the devastating influenza of 1918,\nThe Murmur of Bees\ncaptures both the fate of a country in flux and the destiny of one family that has put their love, faith, and future in the unbelievable.\n

", + "subject": [ + { + "code": "FBFIC000000", + "name": "Fiction", + "scheme": "http://www.feedbooks.com/categories", + "links": [ + { + "type": "application/opds+json", + "href": "https://example.com/category/FBFIC000000.json?lang=en" + } + ] + }, + { + "code": "FBFIC014000", + "name": "Historical", + "scheme": "http://www.feedbooks.com/categories", + "links": [ + { + "type": "application/opds+json", + "href": "https://example.com/category/FBFIC014000.json?lang=en" + } + ] + }, + { + "code": "FBFIC019000", + "name": "Literary", + "scheme": "http://www.feedbooks.com/categories", + "links": [ + { + "type": "application/opds+json", + "href": "https://example.com/category/FBFIC019000.json?lang=en" + } + ] + } + ] + }, + "images": [ + { + "href": "https://covers.example.com/item/4154969.jpg?size=large", + "type": "image/jpeg", + "width": 260, + "height": 420 + }, + { + "href": "https://covers.example.com/item/4154969.jpg", + "type": "image/jpeg", + "width": 100, + "height": 180 + } + ], + "licenses": [ + { + "metadata": { + "identifier": "urn:uuid:ed3c2fe4-d44d-427e-afde-cf246d21ecd9", + "format": [ + "application/epub+zip", + "text/html" + ], + "created": "2021-06-14T19:46:17+02:00", + "terms": { + "expires": "{{expires_1}}", + "checkouts": 40, + "concurrency": 10, + "length": 5097600 + }, + "protection": { + "format": [ + "application/vnd.adobe.adept+xml", + "application/vnd.readium.lcp.license.v1.0+json" + ], + "devices": 6, + "copy": true, + "print": false, + "tts": false + } + }, + "links": [ + { + "rel": "http://opds-spec.org/acquisition/borrow", + "href": "https://license.example.com/loan/status/{?id,checkout_id,expires,patron_id,notification_url,passphrase,hint,hint_url}", + "type": "application/vnd.readium.license.status.v1.0+json", + "templated": true + }, + { + "rel": "self", + "href": "https://license.example.com/copy/status/?uuid=ed3c2fe4-d44d-427e-afde-cf246d21ecd9", + "type": "application/vnd.odl.info+json" + } + ] + }, + { + "metadata": { + "identifier": "urn:uuid:c9079f86-3cdb-4144-b106-04bd70973586", + "format": [ + "application/epub+zip", + "text/html" + ], + "created": "2021-07-09T15:08:55+02:00", + "terms": { + "expires": "{{expires_2}}", + "checkouts": 40, + "concurrency": 10, + "length": 5097600 + }, + "protection": { + "format": [ + "application/vnd.adobe.adept+xml", + "application/vnd.readium.lcp.license.v1.0+json" + ], + "devices": 6, + "copy": true, + "print": false, + "tts": false + } + }, + "links": [ + { + "rel": "http://opds-spec.org/acquisition/borrow", + "href": "https://license.example.com/loan/status/{?id,checkout_id,expires,patron_id,notification_url,passphrase,hint,hint_url}", + "type": "application/vnd.readium.license.status.v1.0+json", + "templated": true + }, + { + "rel": "self", + "href": "https://license.example.com/copy/status/?uuid=c9079f86-3cdb-4144-b106-04bd70973586", + "type": "application/vnd.odl.info+json" + } + ] + }, + { + "metadata": { + "identifier": "urn:uuid:49f6bb8d-1912-4b95-8e62-a65d7c8c02fd", + "format": [ + "application/epub+zip", + "text/html" + ], + "created": "2021-07-09T15:08:55+02:00", + "terms": { + "expires": "{{expires_3}}", + "checkouts": 40, + "concurrency": 10, + "length": 5097600 + }, + "protection": { + "format": [ + "application/vnd.adobe.adept+xml", + "application/vnd.readium.lcp.license.v1.0+json" + ], + "devices": 6, + "copy": true, + "print": false, + "tts": false + } + }, + "links": [ + { + "rel": "http://opds-spec.org/acquisition/borrow", + "href": "https://license.example.com/loan/status/{?id,checkout_id,expires,patron_id,notification_url,passphrase,hint,hint_url}", + "type": "application/vnd.readium.license.status.v1.0+json", + "templated": true + }, + { + "rel": "self", + "href": "https://license.example.com/copy/status/?uuid=49f6bb8d-1912-4b95-8e62-a65d7c8c02fd", + "type": "application/vnd.odl.info+json" + } + ] + } + ] + } + ] +} diff --git a/tests/test_odl.py b/tests/test_odl.py index a7028f90d4..b4e20739ab 100644 --- a/tests/test_odl.py +++ b/tests/test_odl.py @@ -2,6 +2,7 @@ import json import os import urllib.parse +from typing import Callable, List, Tuple import dateutil import pytest @@ -29,10 +30,12 @@ ExternalIntegration, Hold, Hyperlink, + LicensePool, Loan, MediaTypes, Representation, RightsStatus, + Work ) from core.scripts import RunCollectionMonitorScript from core.testing import DatabaseTest @@ -1933,10 +1936,11 @@ def canonicalize_author_name(self, identifier, working_display_name): class TestODLExpiredItemsReaper(DatabaseTest, BaseODLTest): ODL_PROTOCOL = ODLAPI.NAME - ODL_FEED_FILENAME_WITH_SINGLE_ODL_LICENSE = "single_license.opds" - ODL_LICENSE_EXPIRATION_TIME_PLACEHOLDER = "{{expires}}" + ODL_FEED_FILENAME = None + ODL_LICENSE_EXPIRATION_TIME_PLACEHOLDER = [] ODL_REAPER_CLASS = ODLExpiredItemsReaper - SECONDS_PER_HOUR = 3600 + LICENSES_AVAILABLE = None + LICENSES_LEFT = None def _create_importer(self, collection, http_get): """Create a new ODL importer with the specified parameters. @@ -1959,30 +1963,27 @@ def _create_importer(self, collection, http_get): return importer - def _get_test_feed_with_single_odl_license(self, expires): - """Get the feed with a single ODL license with the specific expiration date. + def _get_test_feed(self, expires: List[datetime.datetime]) -> str: + """Get the feed and template the specific expiration dates. - :param expires: Expiration date of the ODL license - :type expires: datetime.datetime + :param expires: List of expiration dates for the ODL licenses in feed. - :return: Test ODL feed with a single ODL license with the specific expiration date - :rtype: str + :return: Test ODL feed. """ - feed = self.get_data(self.ODL_FEED_FILENAME_WITH_SINGLE_ODL_LICENSE) - feed = feed.replace(self.ODL_LICENSE_EXPIRATION_TIME_PLACEHOLDER, expires.isoformat()) + feed = self.get_data(self.ODL_FEED_FILENAME) + for idx, expire in enumerate(expires): + feed = feed.replace(self.ODL_LICENSE_EXPIRATION_TIME_PLACEHOLDER[idx], expire.isoformat()) return feed - def _import_test_feed_with_single_odl_license(self, expires): - """Import the test ODL feed with a single ODL license with the specific expiration date. + def _import_test_feed(self, expires: List[datetime.datetime]) -> Tuple[List[Edition], List[LicensePool], List[Work]]: + """Import the test ODL feed, templated with specific expiration dates. :param expires: Expiration date of the ODL license - :type expires: datetime.datetime :return: 3-tuple containing imported editions, license pools and works - :rtype: Tuple[List[Edition], List[LicensePool], List[Work]] """ - feed = self._get_test_feed_with_single_odl_license(expires) + feed = self._get_test_feed(expires) data_source = DataSource.lookup(self._db, "Feedbooks", autocreate=True) collection = MockODLAPI.mock_collection(self._db, protocol=self.ODL_PROTOCOL) collection.external_integration.set_setting( @@ -1991,7 +1992,8 @@ def _import_test_feed_with_single_odl_license(self, expires): ) license_status = { "checkouts": { - "available": 1 + "available": self.LICENSES_AVAILABLE, + "left": self.LICENSES_LEFT } } license_status_response = MagicMock(return_value=(200, {}, json.dumps(license_status))) @@ -2003,6 +2005,12 @@ def _import_test_feed_with_single_odl_license(self, expires): return imported_editions, imported_pools, imported_works + +class TestODLExpiredItemsReaperSingleLicense(TestODLExpiredItemsReaper): + ODL_FEED_FILENAME = "single_license.opds" + ODL_LICENSE_EXPIRATION_TIME_PLACEHOLDER = ["{{expires}}"] + LICENSES_AVAILABLE = 1 + @freeze_time("2021-01-01T00:00:00+00:00") def test_odl_importer_skips_expired_licenses(self): """Ensure ODLImporter skips expired licenses @@ -2010,10 +2018,8 @@ def test_odl_importer_skips_expired_licenses(self): # 1.1. Import the test feed with an expired ODL license. # The license expires 2021-01-01T00:01:00+01:00 that equals to 2010-01-01T00:00:00+00:00, the current time. # It means the license had already expired at the time of the import. - license_expiration_date = datetime.datetime(2021, 1, 1, 1, 0, 0, tzinfo=tzoffset(None, self.SECONDS_PER_HOUR)) - imported_editions, imported_pools, imported_works = self._import_test_feed_with_single_odl_license( - license_expiration_date - ) + license_expiration_date = dateutil.parser.isoparse("2021-01-01T00:01:00+01:00") + imported_editions, imported_pools, imported_works = self._import_test_feed([license_expiration_date]) # Commit to expire the SQLAlchemy cache. self._db.commit() @@ -2034,9 +2040,7 @@ def test_odl_reaper_removes_expired_licenses(self): # 1.1. Import the test feed with an ODL license that is still valid. # The license will be valid for one more day since this very moment. license_expiration_date = datetime_helpers.utc_now() + datetime.timedelta(days=1) - imported_editions, imported_pools, imported_works = self._import_test_feed_with_single_odl_license( - license_expiration_date - ) + imported_editions, imported_pools, imported_works = self._import_test_feed([license_expiration_date]) # Commit to expire the SQLAlchemy cache. self._db.commit() @@ -2067,9 +2071,8 @@ def test_odl_reaper_removes_expired_licenses(self): assert imported_pool.licenses_available == 1 # 4. Expire the license. - # Set the expiration date to 2021-01-01T00:01:00+01:00 - # that equals to 2010-01-01T00:00:00+00:00, the current time. - license.expires = datetime.datetime(2021, 1, 1, 1, 0, 0, tzinfo=tzoffset(None, self.SECONDS_PER_HOUR)) + # Set the expiration date to yesterday. + license.expires = datetime_helpers.utc_now() - datetime.timedelta(days=1) # 5.1. Run ODLExpiredItemsReaper again. This time it should remove the expired license. script.run() @@ -2090,3 +2093,99 @@ def test_odl_reaper_removes_expired_licenses(self): # 6.2. Ensure that number of licenses is still 0. assert imported_pool.licenses_owned == 0 assert imported_pool.licenses_available == 0 + + +class TestODLExpiredItemsReaperMultipleLicense(TestODLExpiredItemsReaper): + ODL_FEED_FILENAME = "multiple_license.opds" + ODL_LICENSE_EXPIRATION_TIME_PLACEHOLDER = ["{{expires_1}}", "{{expires_2}}", "{{expires_3}}"] + LICENSES_AVAILABLE = 8 + LICENSES_LEFT = 18 + LICENSES_OWNED = 10 + + @freeze_time("2021-01-01T00:00:00+00:00") + def test_odl_importer_skips_expired_licenses(self): + """Ensure ODLImporter skips expired licenses + and does not count them in the total number of available licenses.""" + # 1.1. Import the test feed with one expired ODL license and two valid licenses. + license_expiration_dates = [ + datetime_helpers.utc_now() - datetime.timedelta(days=1), # Expired + datetime_helpers.utc_now() + datetime.timedelta(days=1), # Valid + datetime_helpers.utc_now() + datetime.timedelta(weeks=12) # Valid + ] + imported_editions, imported_pools, imported_works = self._import_test_feed(license_expiration_dates) + + # Commit to expire the SQLAlchemy cache. + self._db.commit() + + # 1.2. Ensure that the license pool was successfully created + assert len(imported_pools) == 1 + [imported_pool] = imported_pools + + # 1.3. Ensure that the two valid licenses were imported + assert len(imported_pool.licenses) == 2 + + # 1.4 Make sure that 20 licenses are marked as owned (10 from each valid license) + assert imported_pool.licenses_owned == self.LICENSES_OWNED * 2 + assert imported_pool.licenses_available == self.LICENSES_AVAILABLE * 2 + + @freeze_time("2021-01-01T00:00:00+00:00") + def test_odl_reaper_removes_expired_licenses(self): + """Ensure ODLExpiredItemsReaper removes expired licenses.""" + # 1.1. Import the test feed with an ODL licenses that are still valid. + license_expiration_dates = [ + datetime_helpers.utc_now() + datetime.timedelta(days=1), + datetime_helpers.utc_now() + datetime.timedelta(days=60), + datetime_helpers.utc_now() + datetime.timedelta(days=365) + ] + imported_editions, imported_pools, imported_works = self._import_test_feed(license_expiration_dates) + + # Commit to expire the SQLAlchemy cache. + self._db.commit() + + # 1.2. Ensure that there is a license pool with available license. + assert len(imported_pools) == 1 + + [imported_pool] = imported_pools + assert imported_pool.licenses_owned == 3 * self.LICENSES_OWNED + assert imported_pool.licenses_available == 3 * self.LICENSES_AVAILABLE + assert len(imported_pool.licenses) == 3 + + [license1, license2, license3] = imported_pool.licenses + assert license1.expires == license_expiration_dates[0] + assert license2.expires == license_expiration_dates[1] + assert license3.expires == license_expiration_dates[2] + + # 2.1. Run ODLExpiredItemsReaper. This time nothing should happen since the license is still valid. + script = RunCollectionMonitorScript(self.ODL_REAPER_CLASS, _db=self._db, cmd_args=["Test ODL Collection"]) + script.run() + + # Commit to expire the SQLAlchemy cache. + self._db.commit() + + # 2.2. Ensure that availability of the license pool didn't change. + assert imported_pool.licenses_owned == 3 * self.LICENSES_OWNED + assert imported_pool.licenses_available == 3 * self.LICENSES_AVAILABLE + assert len(imported_pool.licenses) == 3 + + # 3. Expire the license. + license1.expires = datetime_helpers.utc_now() - datetime.timedelta(days=1) + + # 3.1. Run ODLExpiredItemsReaper again. This time it should remove the expired license. + script.run() + + # Commit to expire the SQLAlchemy cache. + self._db.commit() + + # 3.2. Ensure that availability of the license pool was updated + assert imported_pool.licenses_owned == 2 * self.LICENSES_OWNED + assert imported_pool.licenses_available == 2 * self.LICENSES_AVAILABLE + + # 4.1. Run ODLExpiredItemsReaper again to ensure that number of licenses won't become negative. + script.run() + + # Commit to expire the SQLAlchemy cache. + self._db.commit() + + # 4.2. Ensure that number of licenses is still 0. + assert imported_pool.licenses_owned == 2 * self.LICENSES_OWNED + assert imported_pool.licenses_available == 2 * self.LICENSES_AVAILABLE diff --git a/tests/test_odl2.py b/tests/test_odl2.py index 85d4370096..28579f68a7 100644 --- a/tests/test_odl2.py +++ b/tests/test_odl2.py @@ -28,7 +28,10 @@ from core.model.configuration import ConfigurationFactory, ConfigurationStorage from core.opds2_import import RWPMManifestParser from core.tests.test_opds2_import import OPDS2Test -from tests.test_odl import TestODLExpiredItemsReaper +from tests.test_odl import ( + TestODLExpiredItemsReaperMultipleLicense, + TestODLExpiredItemsReaperSingleLicense, +) class TestODL2Importer(OPDS2Test): @@ -253,13 +256,12 @@ def test(self): assert str(huck_finn_semantic_error) == huck_finn_failure.exception -class TestODL2ExpiredItemsReaper(TestODLExpiredItemsReaper): +class TestODL2ExpiredItemsReaper: __base_path = os.path.split(__file__)[0] resource_path = os.path.join(__base_path, "files", "odl2") - ODL_PROTOCOL = ODL2API.NAME - ODL_FEED_FILENAME_WITH_SINGLE_ODL_LICENSE = "single_license.json" ODL_REAPER_CLASS = ODL2ExpiredItemsReaper + ODL_PROTOCOL = ODL2API.NAME def _create_importer(self, collection, http_get): """Create a new ODL importer with the specified parameters. @@ -282,3 +284,11 @@ def _create_importer(self, collection, http_get): ) return importer + + +class TestODL2ExpiredItemsReaperSingleLicensee(TestODL2ExpiredItemsReaper, TestODLExpiredItemsReaperSingleLicense): + ODL_FEED_FILENAME = "single_license.json" + + +class TestODL2ExpiredItemsReaperMultipleLicense(TestODL2ExpiredItemsReaper, TestODLExpiredItemsReaperMultipleLicense): + ODL_FEED_FILENAME = "multiple_license.json"