Skip to content

Commit 2687c6c

Browse files
jmclaude
andcommitted
test: harden suite after adversarial review (faithfulness + gaps)
Acting on a two-reviewer audit (workflow + Opus-5 advisor): Faithfulness fixes (tests that could pass against a broken library): - get_bitstreams embedded-link test used an href byte-identical to the fallback URL, so it could not distinguish the branches; give it a distinct href that the fallback could not produce. - MCP chain fed a link-bearing Bundle to get_bitstreams; the real consumer round-trips through as_dict() (drops _links) and hits the fallback URL - rebuild the Bundle from as_dict() and assert the fallback is used. - export chain fetched policies with action='READ' (raw-client default) but the real exporter goes through a wrapper defaulting to action=None (no filter); call with action=None and assert no action param is sent. - get_resourcepolicy empty test used a no-_embedded body (defensive branch); the live API returns an _embedded envelope even when empty - use that. Coverage / stronger assertions: - get_resourcepolicy action=None omits the filter and returns all actions. - create_item now asserts the POST body (name/metadata/type/flags), not just the uuid; create_bitstream asserts the multipart 'properties' payload (name/bundleName/metadata). - search_objects result now asserts .as_dict() and links['self']['href'], the two accessors every consumer reads. - model tests assert parsed .metadata and checkSum.checkSumAlgorithm (dropped the tautological hard-set .type assertions' reliance). - get_bitstreams non-200, and create_item/create_bundle server-error: characterization tests pinning the current (non-fail-safe) behavior the consumers depend on, flagged in-comment for a future library hardening. 60 tests, still no network. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7937db1 commit 2687c6c

6 files changed

Lines changed: 158 additions & 23 deletions

File tree

requirements-test.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Test-only dependencies for the dspace_rest_client suite.
2-
# The library itself only needs `requests` (see setup.py); these add the test
3-
# runner and an HTTP transport mock so tests never touch a real DSpace server.
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).
45
pytest>=7.0
56
requests-mock>=1.11
7+
requests

tests/_helpers.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
response parsing - the two things downstream code (this repo) depends on -
88
fails a test instead of silently shipping.
99
"""
10+
import json
1011
import os
12+
import re
1113
import sys
1214
from urllib.parse import urlparse, parse_qs
1315

@@ -52,6 +54,21 @@ def sent_params(request) -> dict:
5254
return parse_qs(urlparse(request.url).query)
5355

5456

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+
return json.loads(m.group(1)) if m else None
70+
71+
5572
# --- response-body builders (shape mirrors the DSpace 7 REST API) --------- #
5673

5774
def embedded(key: str, resources: list) -> dict:

tests/test_client_read.py

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ class TestSearchObjects(unittest.TestCase):
2020

2121
def test_builds_url_params_and_parses_objects(self):
2222
c = make_client()
23+
# every caller's next step is dso.as_dict() + dso.links['self']['href']
24+
# (repo._search.dso2dict, mcp._dso_to_dict), so the search result must
25+
# carry both - include a self link to prove it survives parsing.
26+
obj1 = item_json("u1", "A", _links={"self": {"href": f"{API}/items/u1"}})
2327
body = {"_embedded": {"searchResult": {
2428
"page": {"totalElements": 2, "size": 100},
2529
"_embedded": {"objects": [
26-
{"_embedded": {"indexableObject": item_json("u1", "A")}},
30+
{"_embedded": {"indexableObject": obj1}},
2731
{"_embedded": {"indexableObject": item_json("u2", "B")}},
2832
]}}}}
2933
with requests_mock.Mocker() as m:
@@ -32,6 +36,9 @@ def test_builds_url_params_and_parses_objects(self):
3236
res = c.search_objects(query="dc.identifier:123", size=100,
3337
page=0, details=details)
3438
self.assertEqual([d.uuid for d in res], ["u1", "u2"])
39+
# the two accessors every consumer reads off a search hit
40+
self.assertEqual(res[0].links["self"]["href"], f"{API}/items/u1")
41+
self.assertEqual(res[0].as_dict()["uuid"], "u1")
3542
p = sent_params(m.last_request)
3643
self.assertEqual(p["query"], ["dc.identifier:123"])
3744
self.assertEqual(p["size"], ["100"])
@@ -132,30 +139,53 @@ class TestGetBitstreams(unittest.TestCase):
132139

133140
def test_by_bundle_uses_embedded_link(self):
134141
c = make_client()
135-
href = f"{API}/core/bundles/bnd/bitstreams"
142+
# href deliberately NOT equal to the fallback URL (.../bundles/bnd/
143+
# bitstreams): if the embedded-link branch were removed the client would
144+
# build the fallback, which is unmocked, and this test would fail.
145+
href = f"{API}/core/bundles/HREF-ONLY-PATH/bitstreams"
136146
bundle = Bundle(bundle_json("bnd", bitstreams_href=href))
137147
body = embedded("bitstreams", [bitstream_json("s1", "a.pdf", size=10)])
138148
with requests_mock.Mocker() as m:
139149
m.get(href, json=body)
140150
bs = c.get_bitstreams(bundle=bundle, size=500)
141151
self.assertEqual([b.uuid for b in bs], ["s1"])
142152
self.assertEqual(bs[0].sizeBytes, 10)
153+
self.assertEqual(m.last_request.url.split("?")[0], href)
143154
self.assertEqual(sent_params(m.last_request)["size"], ["500"])
144155

145156
def test_by_bundle_without_link_constructs_url(self):
146157
c = make_client()
147158
bundle = Bundle(bundle_json("bnd2")) # no _links -> manual URL
148159
with requests_mock.Mocker() as m:
149160
m.get(f"{API}/core/bundles/bnd2/bitstreams",
150-
json=embedded("bitstreams", []))
151-
self.assertEqual(c.get_bitstreams(bundle=bundle), [])
161+
json=embedded("bitstreams",
162+
[bitstream_json("s9", "x.pdf", size=7)]))
163+
bs = c.get_bitstreams(bundle=bundle)
164+
# proves both the constructed URL AND parsing on the fallback path
165+
self.assertEqual([b.uuid for b in bs], ["s9"])
166+
self.assertEqual(bs[0].sizeBytes, 7)
152167

153168
def test_no_args_returns_empty_list(self):
154169
c = make_client()
155170
with requests_mock.Mocker() as m:
156171
self.assertEqual(c.get_bitstreams(), [])
157172
self.assertEqual(m.call_count, 0)
158173

174+
def test_non_200_currently_raises_no_failsafe(self):
175+
# CHARACTERIZATION of a known sharp edge: unlike get_bundles (which since
176+
# PR #16 returns [] on a 404), get_bitstreams has no fail-safe - a non-200
177+
# makes fetch_resource return None which this method then subscripts, so
178+
# it raises. The consumers (export/_dspace, reposync/_files) iterate the
179+
# result unguarded, so this is a real crash risk. Pinned deliberately: if
180+
# the library is hardened to return [], update this test to assert that.
181+
c = make_client()
182+
bundle = Bundle(bundle_json("bnd2"))
183+
with requests_mock.Mocker() as m:
184+
m.get(f"{API}/core/bundles/bnd2/bitstreams",
185+
status_code=500, text="boom")
186+
with self.assertRaises(Exception):
187+
c.get_bitstreams(bundle=bundle)
188+
159189

160190
class TestGetCollections(unittest.TestCase):
161191

@@ -220,11 +250,28 @@ def test_parses_live_policies_and_sends_uuid_action(self):
220250
self.assertEqual(p["uuid"], [BITSTREAM_UUID])
221251
self.assertEqual(p["action"], ["READ"])
222252

223-
def test_no_embedded_returns_empty_list(self):
253+
def test_action_none_omits_the_action_filter(self):
254+
# The bitstream export path calls this via the ingest wrapper whose
255+
# default is action=None (ingest/_dspace.py get_resourcepolicy), which
256+
# must fetch policies of ALL actions - so no `action` param is sent.
257+
c = make_client()
258+
body = embedded("resourcepolicies", [
259+
policy_json(pid=1, action="READ"),
260+
policy_json(pid=2, action="WRITE")])
261+
with requests_mock.Mocker() as m:
262+
m.get(f"{API}/authz/resourcepolicies/search/resource", json=body)
263+
rps = c.get_resourcepolicy(BITSTREAM_UUID, action=None)
264+
self.assertEqual([rp.action for rp in rps], ["READ", "WRITE"])
265+
p = sent_params(m.last_request)
266+
self.assertEqual(p["uuid"], [BITSTREAM_UUID])
267+
self.assertNotIn("action", p)
268+
269+
def test_empty_result_set_returns_empty_list(self):
270+
# The live endpoint returns an _embedded envelope even when empty.
224271
c = make_client()
225272
with requests_mock.Mocker() as m:
226273
m.get(f"{API}/authz/resourcepolicies/search/resource",
227-
json={"page": {"totalElements": 0}})
274+
json=embedded("resourcepolicies", []))
228275
self.assertEqual(c.get_resourcepolicy(BITSTREAM_UUID), [])
229276

230277
def test_invalid_uuid_returns_none_without_request(self):

tests/test_client_write.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414

1515
import _helpers # noqa: F401
1616
from _helpers import (
17-
make_client, sent_params, bundle_json, bitstream_json, item_json,
18-
API, ITEM_UUID, COLLECTION_UUID, BITSTREAM_UUID, ANON_GROUP_UUID)
17+
make_client, sent_params, multipart_properties, bundle_json,
18+
bitstream_json, item_json, API, ITEM_UUID, COLLECTION_UUID,
19+
BITSTREAM_UUID, ANON_GROUP_UUID)
1920
from dspace_rest_client.models import Item, Bundle, Bitstream
2021

2122

@@ -100,6 +101,21 @@ def test_posts_to_item_bundles_and_returns_bundle(self):
100101
def test_none_parent_returns_none(self):
101102
self.assertIsNone(make_client().create_bundle(parent=None))
102103

104+
def test_server_error_returns_truthy_uuidless_bundle(self):
105+
# CHARACTERIZATION: like create_item, create_bundle wraps the response
106+
# unconditionally -> a truthy Bundle with uuid=None on failure, not None.
107+
# The importer's `if not bundle` guard (reposync/_importer.py:118-120)
108+
# never fires because a Bundle instance is always truthy. Pinned.
109+
c = make_client()
110+
parent = Item(item_json(ITEM_UUID))
111+
with requests_mock.Mocker() as m:
112+
m.post(f"{API}/core/items/{ITEM_UUID}/bundles",
113+
status_code=500, text="boom")
114+
out = c.create_bundle(parent=parent)
115+
self.assertIsInstance(out, Bundle)
116+
self.assertIsNone(out.uuid)
117+
self.assertTrue(out)
118+
103119

104120
class TestCreateItem(unittest.TestCase):
105121

@@ -114,6 +130,27 @@ def test_posts_with_owning_collection_param_and_returns_item(self):
114130
self.assertEqual(out.uuid, "newu")
115131
self.assertEqual(sent_params(m.last_request)["owningCollection"],
116132
[COLLECTION_UUID])
133+
# the POST body is item.as_dict() - this is how the importer's built
134+
# metadata actually reaches DSpace, so pin it, not just the uuid.
135+
body = m.last_request.json()
136+
self.assertEqual(body["name"], "New thesis")
137+
self.assertEqual(body["type"], "item")
138+
self.assertEqual(body["metadata"], {})
139+
self.assertIs(body["inArchive"], True)
140+
141+
def test_server_error_returns_truthy_uuidless_item(self):
142+
# CHARACTERIZATION: create_item wraps the response unconditionally, so a
143+
# failed create yields a truthy Item with uuid=None, NOT None. The
144+
# importer guards with `if dso is None` (reposync/_importer.py:127-129),
145+
# which therefore never fires on failure. Pinned; see the fail-safe note.
146+
c = make_client()
147+
item = Item({"name": "x", "metadata": {}})
148+
with requests_mock.Mocker() as m:
149+
m.post(f"{API}/core/items", status_code=500, text="boom")
150+
out = c.create_item(parent=COLLECTION_UUID, item=item)
151+
self.assertIsInstance(out, Item)
152+
self.assertIsNone(out.uuid)
153+
self.assertTrue(out) # truthy despite the failure
117154

118155
def test_non_item_returns_none(self):
119156
self.assertIsNone(make_client().create_item(
@@ -145,9 +182,15 @@ def test_success_multipart_upload_returns_bitstream(self):
145182
self.assertIsInstance(bs, Bitstream)
146183
self.assertEqual(bs.uuid, "bsnew")
147184
self.assertEqual(bs.sizeBytes, 20)
148-
# the request really was a multipart file upload
185+
# the request really was a multipart file upload...
149186
self.assertIn("multipart/form-data",
150187
m.last_request.headers["Content-Type"])
188+
# ...carrying the name/bundleName/metadata that actually attach the
189+
# bitstream's metadata in DSpace (reposync/_utils.create_new_bitstream)
190+
props = multipart_properties(m.last_request)
191+
self.assertEqual(props["name"], "a.pdf")
192+
self.assertEqual(props["bundleName"], "ORIGINAL") # == bundle.name
193+
self.assertEqual(props["metadata"], {"dc.title": [{"value": "a.pdf"}]})
151194

152195
def test_server_error_returns_none(self):
153196
c = make_client()

tests/test_models.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,12 @@ def test_bundle_fields_and_bitstreams_link(self):
7373
# get_bitstreams(bundle=...) prefers this embedded link over a manually
7474
# constructed URL, so it is part of the contract.
7575
b = Bundle({"uuid": "b1", "name": "ORIGINAL", "type": "bundle",
76-
"metadata": {},
76+
"metadata": {"dc.title": [{"value": "ORIGINAL"}]},
7777
"_links": {"bitstreams": {"href": "http://x/bundles/b1/bitstreams"}}})
7878
self.assertEqual((b.uuid, b.name, b.type), ("b1", "ORIGINAL", "bundle"))
79+
# .metadata is parsed from the response (unlike .type, a class constant)
80+
# and is serialised by export/_dspace.py:323, so pin it.
81+
self.assertEqual(b.metadata, {"dc.title": [{"value": "ORIGINAL"}]})
7982
self.assertEqual(b.links["bitstreams"]["href"],
8083
"http://x/bundles/b1/bitstreams")
8184

@@ -90,6 +93,10 @@ def test_bitstream_file_fields(self):
9093
self.assertEqual(b.sizeBytes, 2048)
9194
self.assertEqual(b.sequenceId, 3)
9295
self.assertEqual(b.checkSum["value"], "deadbeef")
96+
# the checksum verifier compares checkSumAlgorithm == "MD5"
97+
# (reposync/_files.py:187-189); .metadata is serialised by the exporter.
98+
self.assertEqual(b.checkSum["checkSumAlgorithm"], "MD5")
99+
self.assertEqual(b.metadata, {"dc.title": [{"value": "f.pdf"}]})
93100
d = b.as_dict()
94101
self.assertEqual(d["sizeBytes"], 2048)
95102
self.assertEqual(d["checkSum"]["value"], "deadbeef")

tests/test_repo_usage_contract.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515

1616
import _helpers # noqa: F401
1717
from _helpers import (
18-
make_client, embedded, item_json, bundle_json, bitstream_json,
18+
make_client, sent_params, embedded, item_json, bundle_json, bitstream_json,
1919
policy_json, API, ITEM_UUID, BITSTREAM_UUID, ANON_GROUP_UUID)
20-
from dspace_rest_client.models import Item
20+
from dspace_rest_client.models import Item, Bundle
2121

2222

2323
class TestBitstreamExportChain(unittest.TestCase):
@@ -33,15 +33,19 @@ def test_full_chain_yields_serialisable_attributes(self):
3333
c = make_client()
3434
item = Item(item_json(ITEM_UUID))
3535
bits_href = f"{API}/core/bundles/{self.BUNDLE_UUID}/bitstreams"
36+
bundle_md = {"dc.title": [{"value": "ORIGINAL"}]}
37+
bs_md = {"dc.description": [{"value": "VŠKP"}]}
3638
with requests_mock.Mocker() as m:
3739
m.get(f"{API}/core/items/{ITEM_UUID}/bundles",
3840
json=embedded("bundles",
3941
[bundle_json(self.BUNDLE_UUID, "ORIGINAL",
40-
bitstreams_href=bits_href)]))
42+
bitstreams_href=bits_href,
43+
metadata=bundle_md)]))
4144
m.get(bits_href,
4245
json=embedded("bitstreams",
4346
[bitstream_json(BITSTREAM_UUID, "thesis.pdf",
44-
size=123, seq=1, checksum="abc")]))
47+
size=123, seq=1, checksum="abc",
48+
metadata=bs_md)]))
4549
m.get(f"{API}/authz/resourcepolicies/search/resource",
4650
json=embedded("resourcepolicies", [policy_json(pid=1)]))
4751

@@ -50,20 +54,26 @@ def test_full_chain_yields_serialisable_attributes(self):
5054
bundle = bundles[0]
5155
self.assertEqual((bundle.name, bundle.uuid, bundle.type),
5256
("ORIGINAL", self.BUNDLE_UUID, "bundle"))
57+
self.assertEqual(bundle.metadata, bundle_md)
5358

54-
bundle_rp = c.get_resourcepolicy(bundle.uuid)
59+
# the exporter calls get_resourcepolicy via a wrapper defaulting to
60+
# action=None (ingest/_dspace.py), so NO action filter is sent.
61+
bundle_rp = c.get_resourcepolicy(bundle.uuid, action=None)
5562
self.assertEqual([rp.as_dict()["groupName"] for rp in bundle_rp],
5663
["Anonymous"])
64+
self.assertNotIn("action", sent_params(m.last_request))
5765

5866
bitstreams = c.get_bitstreams(bundle=bundle, size=1000)
5967
self.assertEqual(len(bitstreams), 1)
6068
b = bitstreams[0]
61-
# the exporter reads exactly these off each bitstream
69+
# a representative set of the attributes the exporter serialises
70+
# (src/export/_dspace.py:333-340) - name/uuid/size/seq/checksum/meta
6271
self.assertEqual((b.name, b.uuid, b.sizeBytes, b.sequenceId),
6372
("thesis.pdf", BITSTREAM_UUID, 123, 1))
6473
self.assertEqual(b.checkSum["value"], "abc")
74+
self.assertEqual(b.metadata, bs_md)
6575

66-
bs_rp = c.get_resourcepolicy(b.uuid)
76+
bs_rp = c.get_resourcepolicy(b.uuid, action=None)
6777
self.assertEqual(bs_rp[0].as_dict()["groupUUID"], ANON_GROUP_UUID)
6878

6979

@@ -104,7 +114,11 @@ class TestMcpBundleWalk(unittest.TestCase):
104114

105115
def test_lookup_then_item_bundles_bitstreams(self):
106116
c = make_client()
107-
bits_href = f"{API}/core/bundles/bnd/bitstreams"
117+
# mcp/core.py drops _links (it round-trips bundles through as_dict), so
118+
# get_bitstreams must use the manually-constructed fallback URL, not the
119+
# embedded link. The link below is a decoy that must NOT be requested.
120+
decoy_href = f"{API}/core/bundles/DECOY-LINK/bitstreams"
121+
fallback = f"{API}/core/bundles/bnd/bitstreams"
108122
with requests_mock.Mocker() as m:
109123
m.get(f"{API}/discover/search/objects", json={"_embedded": {
110124
"searchResult": {"page": {"totalElements": 1},
@@ -115,8 +129,8 @@ def test_lookup_then_item_bundles_bitstreams(self):
115129
m.get(f"{API}/core/items/{ITEM_UUID}/bundles",
116130
json=embedded("bundles",
117131
[bundle_json("bnd", "ORIGINAL",
118-
bitstreams_href=bits_href)]))
119-
m.get(bits_href,
132+
bitstreams_href=decoy_href)]))
133+
m.get(fallback,
120134
json=embedded("bitstreams", [bitstream_json("bs1", "a.pdf")]))
121135

122136
matches = c.search_objects(query="dc.identifier:42")
@@ -130,8 +144,13 @@ def test_lookup_then_item_bundles_bitstreams(self):
130144
bundles = c.get_bundles(parent=parent, size=200)
131145
self.assertEqual(bundles[0].name, "ORIGINAL")
132146

133-
bitstreams = c.get_bitstreams(bundle=bundles[0], size=500)
147+
# mirror mcp: rebuild the Bundle from as_dict() (which strips _links)
148+
# so get_bitstreams takes the fallback-URL branch the consumer hits.
149+
bstub = Bundle(bundles[0].as_dict())
150+
self.assertNotIn("bitstreams", bstub.links)
151+
bitstreams = c.get_bitstreams(bundle=bstub, size=500)
134152
self.assertEqual(bitstreams[0].uuid, "bs1")
153+
self.assertEqual(m.last_request.url.split("?")[0], fallback)
135154

136155

137156
class TestGroupSearchForUuidResolution(unittest.TestCase):

0 commit comments

Comments
 (0)