diff --git a/swh/loader/package/rubygems/loader.py b/swh/loader/package/rubygems/loader.py index 21155ff..d0f004e 100644 --- a/swh/loader/package/rubygems/loader.py +++ b/swh/loader/package/rubygems/loader.py @@ -1,135 +1,168 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information -import json import logging import os +import string from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple import attr - -from swh.loader.package.loader import BasePackageInfo, PackageLoader -from swh.loader.package.utils import cached_method, get_url_body, release_name +from packaging.version import parse as parse_version + +from swh.loader.package.loader import ( + BasePackageInfo, + PackageLoader, + RawExtrinsicMetadataCore, +) +from swh.loader.package.utils import get_url_body, release_name from swh.model import from_disk -from swh.model.model import ObjectType, Person, Release, Sha1Git, TimestampWithTimezone +from swh.model.model import ( + MetadataAuthority, + MetadataAuthorityType, + ObjectType, + Person, + Release, + Sha1Git, + TimestampWithTimezone, +) from swh.storage.interface import StorageInterface logger = logging.getLogger(__name__) @attr.s class RubyGemsPackageInfo(BasePackageInfo): name = attr.ib(type=str) """Name of the package""" version = attr.ib(type=str) """Current version""" built_at = attr.ib(type=Optional[TimestampWithTimezone]) """Version build date""" authors = attr.ib(type=List[Person]) """Authors""" + sha256 = attr.ib(type=str) + """Extid as sha256""" + + MANIFEST_FORMAT = string.Template( + "name $name\nshasum $sha256\nurl $url\nversion $version\nlast_update $built_at" + ) + EXTID_TYPE = "rubygems-manifest-sha256" + EXTID_VERSION = 0 + class RubyGemsLoader(PackageLoader[RubyGemsPackageInfo]): """Load ``.gem`` files from ``RubyGems.org`` into the SWH archive.""" visit_type = "rubygems" def __init__( self, storage: StorageInterface, url: str, + artifacts: List[Dict[str, Any]], + rubygem_metadata: List[Dict[str, Any]], max_content_size: Optional[int] = None, **kwargs, ): super().__init__(storage, url, max_content_size=max_content_size, **kwargs) # Lister URLs are in the ``https://rubygems.org/gems/{pkgname}`` format assert url.startswith("https://rubygems.org/gems/"), ( "Expected rubygems.org url, got '%s'" % url ) - self.gem_name = url[len("https://rubygems.org/gems/") :] - # API docs at ``https://guides.rubygems.org/rubygems-org-api/`` - self.api_base_url = "https://rubygems.org/api/v1" - # Mapping of version number to corresponding metadata from the API - self.versions_info: Dict[str, Dict[str, Any]] = {} + # Convert list of artifacts and rubygem_metadata to a mapping of version + self.artifacts: Dict[str, Dict] = { + artifact["version"]: artifact for artifact in artifacts + } + self.rubygem_metadata: Dict[str, Dict] = { + data["version"]: data for data in rubygem_metadata + } def get_versions(self) -> Sequence[str]: - """Return all versions for the gem being loaded. - - Also stores the detailed information for each version since everything - is present in this API call.""" - versions_info = get_url_body( - f"{self.api_base_url}/versions/{self.gem_name}.json" - ) - versions = [] - - for version_info in json.loads(versions_info): - number = version_info["number"] - self.versions_info[number] = version_info - versions.append(number) - + """Return all versions sorted for the gem being loaded""" + versions = list(self.artifacts.keys()) + versions.sort(key=parse_version) return versions - @cached_method def get_default_version(self) -> str: - latest = get_url_body( - f"{self.api_base_url}/versions/{self.gem_name}/latest.json" + """Get the newest release version of a gem""" + return self.get_versions()[-1] + + def get_metadata_authority(self): + return MetadataAuthority( + type=MetadataAuthorityType.FORGE, + url="https://rubygems.org/", ) - return json.loads(latest)["version"] def _load_directory( self, dl_artifacts: List[Tuple[str, Mapping[str, Any]]], tmpdir: str ) -> Tuple[str, from_disk.Directory]: """Override the directory loading to point it to the actual code. Gem files are uncompressed tarballs containing: - ``metadata.gz``: the metadata about this gem - ``data.tar.gz``: the code and possible binary artifacts - ``checksums.yaml.gz``: checksums """ logger.debug("Unpacking gem file to point to the actual code") uncompressed_path = self.uncompress(dl_artifacts, dest=tmpdir) source_code_tarball = os.path.join(uncompressed_path, "data.tar.gz") return super()._load_directory([(source_code_tarball, {})], tmpdir) def get_package_info( self, version: str ) -> Iterator[Tuple[str, RubyGemsPackageInfo]]: - info = self.versions_info[version] + artifact = self.artifacts[version] + rubygem_metadata = self.rubygem_metadata[version] + filename = artifact["filename"] + gem_name = filename.split(f"-{version}.gem")[0] + authors = rubygem_metadata["authors"].split(", ") + checksums = artifact["checksums"] + + # Get extrinsic metadata + extrinsic_metadata_url = rubygem_metadata["extrinsic_metadata_url"] + extrinsic_metadata = get_url_body(extrinsic_metadata_url) - authors = info["authors"].split(", ") p_info = RubyGemsPackageInfo( - url=f"https://rubygems.org/downloads/{self.gem_name}-{version}.gem", - # See format of gem files in ``_load_directory`` - filename=f"{self.gem_name}-{version}.tar", + url=artifact["url"], + filename=filename, version=version, - built_at=TimestampWithTimezone.from_iso8601(info["built_at"]), - name=self.gem_name, + built_at=TimestampWithTimezone.from_iso8601(rubygem_metadata["date"]), + name=gem_name, authors=[Person.from_fullname(person.encode()) for person in authors], + checksums=checksums, # sha256 checksum + sha256=checksums["sha256"], # sha256 for EXTID + directory_extrinsic_metadata=[ + RawExtrinsicMetadataCore( + format="rubygem-release-json", + metadata=extrinsic_metadata, + ), + ], ) yield release_name(version), p_info def build_release( self, p_info: RubyGemsPackageInfo, uncompressed_path: str, directory: Sha1Git ) -> Optional[Release]: msg = ( f"Synthetic release for RubyGems source package {p_info.name} " f"version {p_info.version}\n" ) return Release( name=p_info.version.encode(), message=msg.encode(), date=p_info.built_at, # TODO multiple authors (T3887) author=p_info.authors[0], target_type=ObjectType.DIRECTORY, target=directory, synthetic=True, ) diff --git a/swh/loader/package/rubygems/tests/data/https_rubygems.org/api_v1_versions_mercurial-wrapper.json b/swh/loader/package/rubygems/tests/data/https_rubygems.org/api_v1_versions_mercurial-wrapper.json deleted file mode 100644 index ddc6e33..0000000 --- a/swh/loader/package/rubygems/tests/data/https_rubygems.org/api_v1_versions_mercurial-wrapper.json +++ /dev/null @@ -1,36 +0,0 @@ -[ - { - "authors": "Fabio Neves", - "built_at": "2014-09-11T00:00:00.000Z", - "created_at": "2014-09-25T09:02:44.313Z", - "description": "A simple wrapper around HG command line tool", - "downloads_count": 2770, - "metadata": {}, - "number": "0.8.5", - "summary": "Mercurial command line ruby wrapper", - "platform": "ruby", - "rubygems_version": "\u003e= 0", - "ruby_version": "\u003e= 0", - "prerelease": false, - "licenses": [], - "requirements": [], - "sha": "cee62e168ffd7d36c565e00f29fa6a0b57ef15c4c14055345b1e01148ec4fab8" - }, - { - "authors": "Fabio Neves", - "built_at": "2014-09-11T00:00:00.000Z", - "created_at": "2014-09-18T08:59:42.895Z", - "description": "A simple wrapper around HG command line tool", - "downloads_count": 2415, - "metadata": {}, - "number": "0.8.4", - "summary": "Mercurial command line ruby wrapper", - "platform": "ruby", - "rubygems_version": "\u003e= 0", - "ruby_version": "\u003e= 0", - "prerelease": false, - "licenses": [], - "requirements": [], - "sha": "ec60f0568f4f8744a0da78089a05e51d1c0e9799a1abfb37f63cdf7ed019c862" - } -] \ No newline at end of file diff --git a/swh/loader/package/rubygems/tests/data/https_rubygems.org/api_v1_versions_mercurial-wrapper_latest.json b/swh/loader/package/rubygems/tests/data/https_rubygems.org/api_v1_versions_mercurial-wrapper_latest.json deleted file mode 100644 index 00a8210..0000000 --- a/swh/loader/package/rubygems/tests/data/https_rubygems.org/api_v1_versions_mercurial-wrapper_latest.json +++ /dev/null @@ -1 +0,0 @@ -{"version":"0.8.5"} \ No newline at end of file diff --git a/swh/loader/package/rubygems/tests/data/https_rubygems.org/api_v2_rubygems_haar_joke_versions_0.0.1.json b/swh/loader/package/rubygems/tests/data/https_rubygems.org/api_v2_rubygems_haar_joke_versions_0.0.1.json new file mode 100644 index 0000000..368f17e --- /dev/null +++ b/swh/loader/package/rubygems/tests/data/https_rubygems.org/api_v2_rubygems_haar_joke_versions_0.0.1.json @@ -0,0 +1 @@ +{"name":"haar_joke","downloads":5075,"version":"0.0.1","version_created_at":"2016-07-23T20:45:17.818Z","version_downloads":2718,"platform":"ruby","authors":"Gemma Gotch","info":"Uses the Chuck Norris joke api and replaces Chuck\n Norris with the Fire Emblem character Haar.","licenses":["MIT"],"metadata":{"allowed_push_host":"https://rubygems.org"},"yanked":false,"sha":"a2ee7052fb8ffcfc4ec0fdb77fae9a36e473f859af196a36870a0f386b5ab55e","project_uri":"https://rubygems.org/gems/haar_joke","gem_uri":"https://rubygems.org/gems/haar_joke-0.0.1.gem","homepage_uri":"https://github.com/pveggie/haarjoke","wiki_uri":null,"documentation_uri":"https://www.rubydoc.info/gems/haar_joke/0.0.1","mailing_list_uri":null,"source_code_uri":null,"bug_tracker_uri":null,"changelog_uri":null,"funding_uri":null,"dependencies":{"development":[{"name":"bundler","requirements":"~\u003e 1.12"},{"name":"rake","requirements":"~\u003e 10.0"},{"name":"rspec","requirements":"~\u003e 3.0"},{"name":"webmock","requirements":"~\u003e 2.1"}],"runtime":[]},"built_at":"2016-07-23T00:00:00.000Z","created_at":"2016-07-23T20:45:17.818Z","description":"Uses the Chuck Norris joke api and replaces Chuck\n Norris with the Fire Emblem character Haar.","downloads_count":2718,"number":"0.0.1","summary":"Returns a joke based on a Chuck Norris joke but featuring Haar.","rubygems_version":"\u003e= 0","ruby_version":"\u003e= 0","prerelease":false,"requirements":[]} \ No newline at end of file diff --git a/swh/loader/package/rubygems/tests/data/https_rubygems.org/api_v2_rubygems_haar_joke_versions_0.0.2.json b/swh/loader/package/rubygems/tests/data/https_rubygems.org/api_v2_rubygems_haar_joke_versions_0.0.2.json new file mode 100644 index 0000000..0fbb117 --- /dev/null +++ b/swh/loader/package/rubygems/tests/data/https_rubygems.org/api_v2_rubygems_haar_joke_versions_0.0.2.json @@ -0,0 +1 @@ +{"name":"haar_joke","downloads":5075,"version":"0.0.2","version_created_at":"2016-11-05T09:59:16.824Z","version_downloads":2357,"platform":"ruby","authors":"Gemma Gotch","info":"Uses the Chuck Norris joke api and replaces Chuck\n Norris with the Fire Emblem character Haar.","licenses":["MIT"],"metadata":{"allowed_push_host":"https://rubygems.org"},"yanked":false,"sha":"85a8cf5f41890e9605265eeebfe9e99aa0350a01a3c799f9f55a0615a31a2f5f","project_uri":"https://rubygems.org/gems/haar_joke","gem_uri":"https://rubygems.org/gems/haar_joke-0.0.2.gem","homepage_uri":"https://github.com/pveggie/haarjoke","wiki_uri":null,"documentation_uri":"https://www.rubydoc.info/gems/haar_joke/0.0.2","mailing_list_uri":null,"source_code_uri":null,"bug_tracker_uri":null,"changelog_uri":null,"funding_uri":null,"dependencies":{"development":[{"name":"bundler","requirements":"~\u003e 1.12"},{"name":"rake","requirements":"~\u003e 10.0"},{"name":"rspec","requirements":"~\u003e 3.0"},{"name":"webmock","requirements":"~\u003e 2.1"}],"runtime":[]},"built_at":"2016-11-05T00:00:00.000Z","created_at":"2016-11-05T09:59:16.824Z","description":"Uses the Chuck Norris joke api and replaces Chuck\n Norris with the Fire Emblem character Haar.","downloads_count":2357,"number":"0.0.2","summary":"Returns a joke based on a Chuck Norris joke but featuring Haar.","rubygems_version":"\u003e= 0","ruby_version":"\u003e= 0","prerelease":false,"requirements":[]} \ No newline at end of file diff --git a/swh/loader/package/rubygems/tests/data/https_rubygems.org/downloads_haar_joke-0.0.1.gem b/swh/loader/package/rubygems/tests/data/https_rubygems.org/downloads_haar_joke-0.0.1.gem new file mode 100644 index 0000000..07a4be1 Binary files /dev/null and b/swh/loader/package/rubygems/tests/data/https_rubygems.org/downloads_haar_joke-0.0.1.gem differ diff --git a/swh/loader/package/rubygems/tests/data/https_rubygems.org/downloads_haar_joke-0.0.2.gem b/swh/loader/package/rubygems/tests/data/https_rubygems.org/downloads_haar_joke-0.0.2.gem new file mode 100644 index 0000000..f6cc475 Binary files /dev/null and b/swh/loader/package/rubygems/tests/data/https_rubygems.org/downloads_haar_joke-0.0.2.gem differ diff --git a/swh/loader/package/rubygems/tests/data/https_rubygems.org/downloads_mercurial-wrapper-0.8.4.gem b/swh/loader/package/rubygems/tests/data/https_rubygems.org/downloads_mercurial-wrapper-0.8.4.gem deleted file mode 100644 index 8eb6d8b..0000000 Binary files a/swh/loader/package/rubygems/tests/data/https_rubygems.org/downloads_mercurial-wrapper-0.8.4.gem and /dev/null differ diff --git a/swh/loader/package/rubygems/tests/data/https_rubygems.org/downloads_mercurial-wrapper-0.8.5.gem b/swh/loader/package/rubygems/tests/data/https_rubygems.org/downloads_mercurial-wrapper-0.8.5.gem deleted file mode 100644 index 6c4141f..0000000 Binary files a/swh/loader/package/rubygems/tests/data/https_rubygems.org/downloads_mercurial-wrapper-0.8.5.gem and /dev/null differ diff --git a/swh/loader/package/rubygems/tests/test_rubygems.py b/swh/loader/package/rubygems/tests/test_rubygems.py index 255ed7c..2e41fe6 100644 --- a/swh/loader/package/rubygems/tests/test_rubygems.py +++ b/swh/loader/package/rubygems/tests/test_rubygems.py @@ -1,26 +1,196 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information +from pathlib import Path + +import pytest + +from swh.loader.package import __version__ from swh.loader.package.rubygems.loader import RubyGemsLoader -from swh.loader.tests import get_stats +from swh.loader.tests import assert_last_visit_matches, check_snapshot, get_stats +from swh.model.hashutil import hash_to_bytes +from swh.model.model import ( + Person, + RawExtrinsicMetadata, + Release, + Snapshot, + SnapshotBranch, + TargetType, + TimestampWithTimezone, +) +from swh.model.model import MetadataFetcher +from swh.model.model import ObjectType as ModelObjectType +from swh.model.swhids import CoreSWHID, ExtendedObjectType, ExtendedSWHID, ObjectType + +ORIGIN = { + "url": "https://rubygems.org/gems/haar_joke", + "artifacts": [ + { + "url": "https://rubygems.org/downloads/haar_joke-0.0.2.gem", + "length": 8704, + "version": "0.0.2", + "filename": "haar_joke-0.0.2.gem", + "checksums": { + "sha256": "85a8cf5f41890e9605265eeebfe9e99aa0350a01a3c799f9f55a0615a31a2f5f" + }, + }, + { + "url": "https://rubygems.org/downloads/haar_joke-0.0.1.gem", + "length": 8704, + "version": "0.0.1", + "filename": "haar_joke-0.0.1.gem", + "checksums": { + "sha256": "a2ee7052fb8ffcfc4ec0fdb77fae9a36e473f859af196a36870a0f386b5ab55e" + }, + }, + ], + "rubygem_metadata": [ + { + "date": "2016-11-05T00:00:00+00:00", + "authors": "Gemma Gotch", + "version": "0.0.2", + "extrinsic_metadata_url": "https://rubygems.org/api/v2/rubygems/haar_joke/versions/0.0.2.json", # noqa: B950 + }, + { + "date": "2016-07-23T00:00:00+00:00", + "authors": "Gemma Gotch", + "version": "0.0.1", + "extrinsic_metadata_url": "https://rubygems.org/api/v2/rubygems/haar_joke/versions/0.0.1.json", # noqa: B950 + }, + ], +} + + +@pytest.fixture +def head_release_extrinsic_metadata(datadir): + return Path( + datadir, + "https_rubygems.org", + "api_v2_rubygems_haar_joke_versions_0.0.2.json", + ).read_bytes() + + +def test_get_versions(requests_mock_datadir, swh_storage): + loader = RubyGemsLoader( + swh_storage, + url=ORIGIN["url"], + artifacts=ORIGIN["artifacts"], + rubygem_metadata=ORIGIN["rubygem_metadata"], + ) + assert loader.get_versions() == ["0.0.1", "0.0.2"] + + +def test_get_default_version(requests_mock_datadir, swh_storage): + loader = RubyGemsLoader( + swh_storage, + url=ORIGIN["url"], + artifacts=ORIGIN["artifacts"], + rubygem_metadata=ORIGIN["rubygem_metadata"], + ) + assert loader.get_default_version() == "0.0.2" -def test_rubygems_loader(swh_storage, requests_mock_datadir): - url = "https://rubygems.org/gems/mercurial-wrapper" - loader = RubyGemsLoader(swh_storage, url) +def test_rubygems_loader( + swh_storage, requests_mock_datadir, head_release_extrinsic_metadata +): + loader = RubyGemsLoader( + swh_storage, + url=ORIGIN["url"], + artifacts=ORIGIN["artifacts"], + rubygem_metadata=ORIGIN["rubygem_metadata"], + ) + load_status = loader.load() + assert load_status["status"] == "eventful" + assert load_status["snapshot_id"] is not None - assert loader.load()["status"] == "eventful" + expected_snapshot_id = "7a646532d6fdd7df84e35d64bf1f3da9ddbd0971" + expected_head_release = "afd15d9042873b8082218433f5dd4db1024defc1" + + assert expected_snapshot_id == load_status["snapshot_id"] + + expected_snapshot = Snapshot( + id=hash_to_bytes(load_status["snapshot_id"]), + branches={ + b"releases/0.0.1": SnapshotBranch( + target=hash_to_bytes("604dbd4f9768e952ae63249edc4084bf0fe85a8c"), + target_type=TargetType.RELEASE, + ), + b"releases/0.0.2": SnapshotBranch( + target=hash_to_bytes(expected_head_release), + target_type=TargetType.RELEASE, + ), + b"HEAD": SnapshotBranch( + target=b"releases/0.0.2", + target_type=TargetType.ALIAS, + ), + }, + ) + + check_snapshot(expected_snapshot, loader.storage) stats = get_stats(swh_storage) assert { - "content": 8, - "directory": 4, + "content": 23, + "directory": 7, "origin": 1, "origin_visit": 1, - "release": 2, + "release": 1 + 1, "revision": 0, "skipped_content": 0, "snapshot": 1, } == stats + + head_release = loader.storage.release_get([hash_to_bytes(expected_head_release)])[0] + + assert head_release == Release( + name=b"0.0.2", + message=b"Synthetic release for RubyGems source package haar_joke version 0.0.2\n", + target=hash_to_bytes("8af199118ef7f6b6c312bcf09c77552442b87a45"), + target_type=ModelObjectType.DIRECTORY, + synthetic=True, + author=Person( + fullname=b"Gemma Gotch", + name=b"", + email=None, + ), + date=TimestampWithTimezone.from_iso8601("2016-11-05T00:00:00+00:00"), + id=hash_to_bytes(expected_head_release), + ) + + assert_last_visit_matches( + loader.storage, + url=ORIGIN["url"], + status="full", + type="rubygems", + snapshot=expected_snapshot.id, + ) + + release_swhid = CoreSWHID(object_type=ObjectType.RELEASE, object_id=head_release.id) + directory_swhid = ExtendedSWHID( + object_type=ExtendedObjectType.DIRECTORY, object_id=head_release.target + ) + expected_metadata = [ + RawExtrinsicMetadata( + target=directory_swhid, + authority=loader.get_metadata_authority(), + fetcher=MetadataFetcher( + name="swh.loader.package.rubygems.loader.RubyGemsLoader", + version=__version__, + ), + discovery_date=loader.visit_date, + format="rubygem-release-json", + metadata=head_release_extrinsic_metadata, + origin=ORIGIN["url"], + release=release_swhid, + ), + ] + + assert ( + loader.storage.raw_extrinsic_metadata_get( + directory_swhid, + loader.get_metadata_authority(), + ).results + == expected_metadata + ) diff --git a/swh/loader/package/rubygems/tests/test_tasks.py b/swh/loader/package/rubygems/tests/test_tasks.py index ad8dba9..bf510e5 100644 --- a/swh/loader/package/rubygems/tests/test_tasks.py +++ b/swh/loader/package/rubygems/tests/test_tasks.py @@ -1,21 +1,61 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information +import uuid -def test_tasks_rubygems_loader( - mocker, swh_scheduler_celery_app, swh_scheduler_celery_worker, swh_config +import pytest + +from swh.scheduler.model import ListedOrigin, Lister + +NAMESPACE = "swh.loader.package.rubygems" + + +@pytest.fixture +def rubygems_lister(): + return Lister(name="rubygems", instance_name="example", id=uuid.uuid4()) + + +@pytest.fixture +def rubygems_listed_origin(rubygems_lister): + return ListedOrigin( + lister_id=rubygems_lister.id, + url="https://rubygems.org/gems/whatever-package", + visit_type="rubygems", + extra_loader_arguments={ + "artifacts": [ + { + "url": "https://rubygems.org/downloads/whatever-package-0.0.1.gem", + "length": 1, + "version": "0.0.1", + "filename": "whatever-package-0.0.1.gem", + "checksums": { + "sha256": "85a8cf5f41890e9605265eeebfe9e99aa0350a01a3c799f9f55a0615a31a2f5f" # noqa: B950 + }, + } + ], + "rubygem_metadata": [ + { + "date": "2016-11-05T00:00:00+00:00", + "authors": "John Dodoe", + "version": "0.0.1", + "extrinsic_metadata_url": "https://rubygems.org/api/v2/rubygems/whatever-package/versions/0.0.1.json", # noqa: B950 + }, + ], + }, + ) + + +def test_rubygems_loader_task_for_listed_origin( + loading_task_creation_for_listed_origin_test, + rubygems_lister, + rubygems_listed_origin, ): - mock_load = mocker.patch("swh.loader.package.rubygems.loader.RubyGemsLoader.load") - mock_load.return_value = {"status": "eventful"} - res = swh_scheduler_celery_app.send_task( - "swh.loader.package.rubygems.tasks.LoadRubyGems", - kwargs={"url": "https://rubygems.org/gems/whatever-package"}, + loading_task_creation_for_listed_origin_test( + loader_class_name=f"{NAMESPACE}.loader.RubyGemsLoader", + task_function_name=f"{NAMESPACE}.tasks.LoadRubyGems", + lister=rubygems_lister, + listed_origin=rubygems_listed_origin, ) - assert res - res.wait() - assert res.successful() - assert mock_load.called - assert res.result == {"status": "eventful"}