Skip to content

Commit 3273055

Browse files
authored
Merge pull request #18 from dataquest-dev/test/dtq-usage-coverage
test: unit + integration suite for downstream usage, with CI
2 parents c9b4872 + a2b7140 commit 3273055

11 files changed

Lines changed: 1118 additions & 8 deletions

File tree

.github/workflows/tests.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [ dtq ]
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
# Matches the DSpace-ISstag-integration consumer CI matrix; the library
18+
# itself declares support for >=3.8 (see setup.py).
19+
python-version: ["3.10", "3.12"]
20+
21+
steps:
22+
- uses: actions/checkout@v6
23+
24+
- name: Set up Python ${{ matrix.python-version }}
25+
uses: actions/setup-python@v6
26+
with:
27+
python-version: ${{ matrix.python-version }}
28+
cache: pip
29+
cache-dependency-path: |
30+
setup.py
31+
requirements-test.txt
32+
33+
- name: Install package + test deps
34+
run: |
35+
python -m pip install --upgrade pip
36+
pip install .
37+
pip install -r requirements-test.txt
38+
39+
- name: Run tests
40+
run: python -m pytest tests/ -v

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
__pycache__/
22
*.py[cod]
33
*$py.class
4+
*.egg-info/
5+
build/
6+
dist/
7+
.pytest_cache/
48
.python-version
59
Pipfile.lock
610
__pypackages__/

dspace_rest_client/client.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -764,7 +764,13 @@ def create_bundle(self, parent=None, name='ORIGINAL'):
764764
if parent is None:
765765
return None
766766
url = f'{self.API_ENDPOINT}/core/items/{parent.uuid}/bundles'
767-
return Bundle(api_resource=parse_json(self.api_post(url, params=None, json={'name': name, 'metadata': {}})))
767+
r = self.api_post(url, params=None, json={'name': name, 'metadata': {}})
768+
if r.status_code not in (200, 201):
769+
# return None on failure (not a uuid-less Bundle) so callers'
770+
# `if not bundle` guards actually fire
771+
_logger.error(f'Failed to create bundle: {r.status_code}: {r.text}')
772+
return None
773+
return Bundle(api_resource=parse_json(r))
768774

769775
# PAGINATION
770776
def get_bitstreams(self, uuid=None, bundle=None, page=0, size=20, sort=None):
@@ -794,12 +800,18 @@ def get_bitstreams(self, uuid=None, bundle=None, page=0, size=20, sort=None):
794800
if sort is not None:
795801
params['sort'] = sort
796802
r_json = self.fetch_resource(url, params=params)
797-
if '_embedded' in r_json:
798-
if 'bitstreams' in r_json['_embedded']:
799-
bitstreams = list()
800-
for bitstream_resource in r_json['_embedded']['bitstreams']:
801-
bitstreams.append(Bitstream(bitstream_resource))
802-
return bitstreams
803+
if r_json is None and getattr(self._last_err, 'status_code', None) == 404:
804+
# the bundle (or item) is gone - no bitstreams, a clean empty result
805+
# rather than a crash. Mirrors get_bundles (#16). Any other failure
806+
# (a transient 5xx, say) falls through and still surfaces to the
807+
# caller so it is retried, not silently recorded as "no bitstreams".
808+
_logger.info(f'No bitstreams: resource not found (404) [{url}]')
809+
return list()
810+
bitstreams = list()
811+
if '_embedded' in r_json and 'bitstreams' in r_json['_embedded']:
812+
for bitstream_resource in r_json['_embedded']['bitstreams']:
813+
bitstreams.append(Bitstream(bitstream_resource))
814+
return bitstreams
803815

804816
def create_bitstream(self, bundle=None, name=None, path=None, mime=None, metadata=None, retry=False):
805817
"""
@@ -1085,7 +1097,12 @@ def create_item(self, parent, item):
10851097
if not isinstance(item, Item):
10861098
_logger.error('Need a valid item')
10871099
return None
1088-
return Item(api_resource=parse_json(self.create_dso(url, params=params, data=item.as_dict())))
1100+
r = self.create_dso(url, params=params, data=item.as_dict())
1101+
if r is None or r.status_code != 201:
1102+
# return None on failure (not a uuid-less Item) so callers'
1103+
# `if dso is None` guards actually fire
1104+
return None
1105+
return Item(api_resource=parse_json(r))
10891106

10901107
def update_item(self, item):
10911108
"""

requirements-test.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Test-only dependencies for the dspace_rest_client suite.
2+
# The test runner and an HTTP transport mock so tests never touch a real DSpace
3+
# server. `requests` is listed explicitly so `pip install -r requirements-test.txt`
4+
# alone is enough to import the package (CI also `pip install .`s it via setup.py).
5+
pytest>=7.0
6+
requests-mock>=1.11
7+
requests

tests/_helpers.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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

tests/conftest.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""
2+
Pytest bootstrap for the dspace_rest_client test-suite.
3+
4+
Ensures the in-tree ``dspace_rest_client`` package is importable when the tests
5+
are run straight from a checkout (``pytest tests/``) without a prior
6+
``pip install``. When the package *is* installed, inserting the source root at
7+
the front of ``sys.path`` means the tests still exercise the working-tree copy,
8+
which is the one we ship and vendor as a submodule.
9+
"""
10+
import os
11+
import sys
12+
13+
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
14+
if _ROOT not in sys.path:
15+
sys.path.insert(0, _ROOT)

tests/test_client_auth.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""
2+
Construction and authentication contract.
3+
4+
``ingest.dspace_be`` constructs the client from an endpoint/user/password and
5+
calls ``authenticate()``; a False return is turned into a hard ConnectionError,
6+
so the True/False semantics here matter.
7+
"""
8+
import unittest
9+
10+
import requests_mock
11+
12+
import _helpers # noqa: F401
13+
from _helpers import make_client, API
14+
15+
16+
class TestConstructor(unittest.TestCase):
17+
18+
def test_endpoints_derived_from_api_endpoint(self):
19+
c = make_client("http://host:8080/server/api")
20+
self.assertEqual(c.API_ENDPOINT, "http://host:8080/server/api")
21+
self.assertEqual(c.LOGIN_URL, "http://host:8080/server/api/authn/login")
22+
self.assertIsNotNone(c.session)
23+
24+
def test_default_last_err_is_none(self):
25+
self.assertIsNone(make_client().last_err)
26+
27+
28+
class TestAuthenticate(unittest.TestCase):
29+
30+
def test_success_returns_true_and_propagates_bearer_token(self):
31+
c = make_client()
32+
with requests_mock.Mocker() as m:
33+
m.post(f"{API}/authn/login", status_code=200,
34+
headers={"Authorization": "Bearer tok123"})
35+
m.get(f"{API}/authn/status", status_code=200,
36+
json={"authenticated": True})
37+
self.assertTrue(c.authenticate())
38+
# the bearer token must land on the session for later calls
39+
self.assertEqual(c.session.headers.get("Authorization"), "Bearer tok123")
40+
41+
def test_invalid_credentials_401_returns_false(self):
42+
c = make_client()
43+
with requests_mock.Mocker() as m:
44+
m.post(f"{API}/authn/login", status_code=401,
45+
json={"message": "invalid"})
46+
self.assertFalse(c.authenticate())
47+
48+
def test_status_not_authenticated_returns_false(self):
49+
c = make_client()
50+
with requests_mock.Mocker() as m:
51+
m.post(f"{API}/authn/login", status_code=200,
52+
headers={"Authorization": "Bearer t"})
53+
m.get(f"{API}/authn/status", status_code=200,
54+
json={"authenticated": False})
55+
self.assertFalse(c.authenticate())
56+
57+
def test_csrf_403_retries_once_then_gives_up(self):
58+
c = make_client()
59+
with requests_mock.Mocker() as m:
60+
m.post(f"{API}/authn/login", status_code=403,
61+
json={"message": "CSRF token required"})
62+
self.assertFalse(c.authenticate())
63+
login_calls = [r for r in m.request_history
64+
if r.path == "/server/api/authn/login"]
65+
# initial attempt + exactly one retry with the refreshed token
66+
self.assertEqual(len(login_calls), 2)
67+
68+
69+
if __name__ == "__main__":
70+
unittest.main()

0 commit comments

Comments
 (0)