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 @@
+
+
\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
\nFrom 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..a8ff5a484c 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,135 @@ 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 + + @freeze_time("2021-01-01T00:00:00+00:00") + def test_odl_reaper_removes_expired_licenses_with_multiple_available(self): + """Ensure ODLExpiredItemsReaper removes expired licenses.""" + # 1.1. Import the test feed with an ODL license that is still valid. Set the number of licenses + # available to 5 to make sure that the licenses are not available once the license expires. + license_expiration_date = datetime_helpers.utc_now() + datetime.timedelta(days=1) + self.LICENSES_AVAILABLE = 5 + imported_editions, imported_pools, imported_works = self._import_test_feed([license_expiration_date]) + + # 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_available == self.LICENSES_AVAILABLE + + assert len(imported_pool.licenses) == 1 + [license] = imported_pool.licenses + assert license.expires == license_expiration_date + + # 2.1. Expire the license. + # Set the expiration date to yesterday. + license.expires = datetime_helpers.utc_now() - datetime.timedelta(days=1) + + # 2.2. Run ODLExpiredItemsReaper. It should remove the expired license. + 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.3. Ensure that availability of the license pool was updated and now it doesn't have any available licenses. + 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"