|
| 1 | +""" |
| 2 | +Shared helpers for the client test-suite. |
| 3 | +
|
| 4 | +The tests mock *only* the HTTP transport (via ``requests_mock``) and let the |
| 5 | +real ``DSpaceClient`` build URLs, send params and parse responses into model |
| 6 | +objects. That way a change to the library that breaks URL construction or |
| 7 | +response parsing - the two things downstream code (this repo) depends on - |
| 8 | +fails a test instead of silently shipping. |
| 9 | +""" |
| 10 | +import json |
| 11 | +import os |
| 12 | +import re |
| 13 | +import sys |
| 14 | +from urllib.parse import urlparse, parse_qs |
| 15 | + |
| 16 | +# Make ``dspace_rest_client`` importable when a test module is run directly |
| 17 | +# (``python tests/test_x.py``), not just under pytest (see conftest.py). |
| 18 | +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 19 | +if _ROOT not in sys.path: |
| 20 | + sys.path.insert(0, _ROOT) |
| 21 | + |
| 22 | +from dspace_rest_client.client import DSpaceClient # noqa: E402 |
| 23 | + |
| 24 | +# Canonical test endpoint. All mocked URLs are built off this so a typo shows |
| 25 | +# up as an unmatched request rather than a false pass. |
| 26 | +API = "http://dspace.test/server/api" |
| 27 | + |
| 28 | +# Real, syntactically-valid UUIDs - several client methods validate their UUID |
| 29 | +# arguments with ``uuid.UUID(...)`` and short-circuit on a bad one, so tests |
| 30 | +# that expect a request to actually go out must use valid values. |
| 31 | +ITEM_UUID = "11111111-1111-1111-1111-111111111111" |
| 32 | +COLLECTION_UUID = "22222222-2222-2222-2222-222222222222" |
| 33 | +BITSTREAM_UUID = "9f54ef33-c454-4d8e-a5fe-79d8291045ba" |
| 34 | +ANON_GROUP_UUID = "6ecfd145-3b7d-429e-ab31-ef6905a05763" |
| 35 | + |
| 36 | + |
| 37 | +def make_client(api_endpoint: str = API) -> DSpaceClient: |
| 38 | + """A real client with no network touched. |
| 39 | +
|
| 40 | + ``DSpaceClient.__init__`` performs no HTTP (it only creates a |
| 41 | + ``requests.Session`` and, optionally, a pysolr handle), so a plain |
| 42 | + construction is safe and gives us the genuine object under test. |
| 43 | + """ |
| 44 | + return DSpaceClient(api_endpoint, "tester@dspace.test", "secret") |
| 45 | + |
| 46 | + |
| 47 | +def sent_params(request) -> dict: |
| 48 | + """Case-preserving query params of a captured request. |
| 49 | +
|
| 50 | + ``requests_mock``'s ``request.qs`` lowercases the whole query string, which |
| 51 | + would mangle case-sensitive values (eg. ``action=READ``). Parsing the |
| 52 | + original ``request.url`` keeps the real casing. |
| 53 | + """ |
| 54 | + return parse_qs(urlparse(request.url).query) |
| 55 | + |
| 56 | + |
| 57 | +def multipart_properties(request) -> dict: |
| 58 | + """Parse the JSON ``properties`` part of a create_bitstream multipart body. |
| 59 | +
|
| 60 | + ``create_bitstream`` sends ``properties = json.dumps({name, metadata, |
| 61 | + bundleName}) + ';application/json'`` as a form field. This is what actually |
| 62 | + carries the bitstream's metadata to DSpace, so tests assert on it. |
| 63 | + """ |
| 64 | + body = request.body |
| 65 | + if isinstance(body, bytes): |
| 66 | + body = body.decode("utf-8", "replace") |
| 67 | + m = re.search(r'name="properties"\r?\n\r?\n(.*?);application/json', |
| 68 | + body, re.DOTALL) |
| 69 | + # fail the test loudly rather than returning None and deferring the error |
| 70 | + assert m is not None, \ |
| 71 | + "create_bitstream multipart body has no JSON 'properties' part" |
| 72 | + return json.loads(m.group(1)) |
| 73 | + |
| 74 | + |
| 75 | +# --- response-body builders (shape mirrors the DSpace 7 REST API) --------- # |
| 76 | + |
| 77 | +def embedded(key: str, resources: list) -> dict: |
| 78 | + """A HAL ``_embedded`` list envelope, eg. ``{"_embedded": {"bundles": [...]}}``.""" |
| 79 | + return {"_embedded": {key: resources}} |
| 80 | + |
| 81 | + |
| 82 | +def item_json(uuid: str = ITEM_UUID, name: str = "Thesis", **extra) -> dict: |
| 83 | + d = {"uuid": uuid, "name": name, "type": "item", "metadata": {}, |
| 84 | + "inArchive": True, "discoverable": True, "withdrawn": False} |
| 85 | + d.update(extra) |
| 86 | + return d |
| 87 | + |
| 88 | + |
| 89 | +def bundle_json(uuid: str = "bnd", name: str = "ORIGINAL", |
| 90 | + bitstreams_href: str = None, **extra) -> dict: |
| 91 | + d = {"uuid": uuid, "name": name, "type": "bundle", "metadata": {}} |
| 92 | + if bitstreams_href is not None: |
| 93 | + d["_links"] = {"bitstreams": {"href": bitstreams_href}} |
| 94 | + d.update(extra) |
| 95 | + return d |
| 96 | + |
| 97 | + |
| 98 | +def bitstream_json(uuid: str = "bs1", name: str = "thesis.pdf", size: int = 123, |
| 99 | + seq: int = 1, checksum: str = "abc", **extra) -> dict: |
| 100 | + d = {"uuid": uuid, "name": name, "type": "bitstream", "metadata": {}, |
| 101 | + "sizeBytes": size, "sequenceId": seq, |
| 102 | + "checkSum": {"checkSumAlgorithm": "MD5", "value": checksum}} |
| 103 | + d.update(extra) |
| 104 | + return d |
| 105 | + |
| 106 | + |
| 107 | +def policy_json(pid: int = 1, action: str = "READ", group_name: str = "Anonymous", |
| 108 | + group_uuid: str = ANON_GROUP_UUID, start_date: str = None) -> dict: |
| 109 | + """A resource policy in the *live* API shape (group under ``_embedded``).""" |
| 110 | + d = {"id": pid, "action": action, |
| 111 | + "_embedded": {"group": {"name": group_name, "uuid": group_uuid}}} |
| 112 | + if start_date is not None: |
| 113 | + d["startDate"] = start_date |
| 114 | + return d |
0 commit comments