Coverage for tests / test_creator_names.py: 100%
184 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-07-17 16:43 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2026-07-17 16:43 +0000
1# SPDX-FileCopyrightText: 2025-2026 Arcangelo Massari <arcangelo.massari@unibo.it>
2#
3# SPDX-License-Identifier: ISC
5import json
6import zipfile
7from copy import deepcopy
8from pathlib import Path
9from unittest.mock import call, patch
11import pytest
12import requests
13import yaml
14from rdflib import Graph
16from changes_metadata_manager.folder_metadata_builder import load_kg
17from changes_metadata_manager.patch.creator_names import (
18 CACHE_FILENAME,
19 MANIFEST_FILENAME,
20 apply_creator_name_patches,
21 prepare_creator_name_patches,
22)
23from changes_metadata_manager.zenodo_api import ZenodoRecordCache
25DATA_DIR = Path(__file__).parent.parent / "data"
28def _creator(
29 family_name: str,
30 given_name: str,
31 orcid: str,
32 role: str,
33 affiliation: str,
34) -> dict:
35 return {
36 "person_or_org": {
37 "type": "personal",
38 "family_name": family_name,
39 "given_name": given_name,
40 "identifiers": [{"scheme": "orcid", "identifier": orcid}],
41 },
42 "role": {"id": role},
43 "affiliations": [{"name": affiliation}],
44 }
47UNIBO = "Alma Mater Studiorum - Università di Bologna"
48CNR = "Consiglio Nazionale delle Ricerche"
49METADATA_CREATORS = [
50 _creator("Massari", "Arcangelo", "0000-0002-8420-0696", "datacurator", UNIBO),
51 _creator("Moretti", "Arianna", "0000-0001-5486-7070", "datacurator", UNIBO),
52 _creator("Barzaghi", "Sebastian", "0000-0002-0799-1527", "datacurator", UNIBO),
53]
54VALENTINA = _creator(
55 "Girelli", "Valentina Alena", "0000-0001-9257-9803", "researcher", UNIBO
56)
57ALICE = _creator("Bordignon", "Alice", "0009-0008-3556-0493", "researcher", UNIBO)
58FEDERICA = _creator("Giacomini", "Federica", "0009-0002-5840-2769", "researcher", UNIBO)
59FRANCESCA = _creator("Fabbri", "Francesca", "0000-0003-4923-9875", "researcher", UNIBO)
60MARIA = _creator("Rega", "Maria Felicia", "0000-0001-8404-1640", "researcher", CNR)
61RACHELE = _creator(
62 "Manganelli Del Fà",
63 "Rachele",
64 "0000-0002-4767-5684",
65 "researcher",
66 CNR,
67)
70@pytest.fixture(scope="module")
71def real_kg():
72 return load_kg(DATA_DIR / "kg.ttl")
75def _draft(
76 record_id: int,
77 status: str = "published",
78 folder_names: tuple[str, ...] = ("S1-40-Test",),
79) -> dict:
80 return {
81 "draft_id": record_id,
82 "zenodo_url": "https://zenodo.org/api",
83 "access_token": "secret-token",
84 "user_agent": "changes-metadata-manager/1.0.0",
85 "status": status,
86 "folder_names": folder_names,
87 }
90def _write_drafts(tmp_path: Path, drafts: list[dict]) -> Path:
91 serialized_drafts = []
92 for source_draft in drafts:
93 draft = dict(source_draft)
94 folder_names = draft.pop("folder_names")
95 zip_path = tmp_path / f"{draft['draft_id']}.zip"
96 with zipfile.ZipFile(zip_path, "w") as archive:
97 for folder_name in folder_names:
98 archive.writestr(f"{folder_name}/raw/meta.ttl", "")
99 config_path = tmp_path / f"{draft['draft_id']}.yaml"
100 config_path.write_text(yaml.safe_dump({"files": [str(zip_path)]}))
101 draft["config_file"] = str(config_path)
102 serialized_drafts.append(draft)
104 drafts_path = tmp_path / "drafts.json"
105 drafts_path.write_text(json.dumps(serialized_drafts))
106 return drafts_path
109def _expanded_creators(creators: list[dict]) -> list[dict]:
110 expanded = deepcopy(creators)
111 for creator in expanded:
112 person = creator["person_or_org"]
113 person["name"] = f"{person['family_name']}, {person['given_name']}"
114 creator["role"]["title"] = {"en": creator["role"]["id"]}
115 creator["affiliations"][0]["id"] = "01ggx4157"
116 return expanded
119def _record(entity_id: str, stage: str, creators: list[dict]) -> dict:
120 return {
121 "access": {
122 "record": "public",
123 "files": "public",
124 "embargo": {"active": False, "reason": None},
125 "status": "open",
126 },
127 "files": {
128 "enabled": True,
129 "order": [],
130 "default_preview": None,
131 "count": 1,
132 "entries": {f"remote-dataset-{stage}.zip": {"size": 123}},
133 },
134 "metadata": {
135 "title": "Title changed directly on Zenodo",
136 "resource_type": {"id": "dataset", "title": {"en": "Dataset"}},
137 "creators": _expanded_creators(creators),
138 "publication_date": "2026-05-27",
139 "description": '<p>Remote <a href="https://example.org">HTML</a>.</p>',
140 "additional_descriptions": [
141 {
142 "description": "<p>Remote method.</p>",
143 "type": {"id": "methods", "title": {"en": "Methods"}},
144 }
145 ],
146 "identifiers": [
147 {
148 "identifier": (
149 f"https://w3id.org/changes/4/aldrovandi/itm/{entity_id}/ob00/1"
150 ),
151 "scheme": "url",
152 }
153 ],
154 "related_identifiers": [
155 {
156 "identifier": "10.1234/example",
157 "scheme": "doi",
158 "relation_type": {
159 "id": "isdocumentedby",
160 "title": {"en": "Is documented by"},
161 },
162 "resource_type": {
163 "id": "publication-article",
164 "title": {"en": "Journal article"},
165 },
166 }
167 ],
168 "rights": [
169 {
170 "title": {"en": "Remote right"},
171 "link": "https://example.org/right",
172 }
173 ],
174 "publisher": "Zenodo",
175 },
176 "custom_fields": {"project:code": "CHANGES"},
177 "id": "server-owned-id",
178 "pids": {"doi": {"identifier": "10.5281/zenodo.1"}},
179 }
182def _expected_payload(expected_creators: list[dict]) -> dict:
183 return {
184 "access": {
185 "record": "public",
186 "files": "public",
187 },
188 "files": {"enabled": True, "order": []},
189 "metadata": {
190 "title": "Title changed directly on Zenodo",
191 "resource_type": {"id": "dataset"},
192 "creators": expected_creators,
193 "publication_date": "2026-05-27",
194 "description": '<p>Remote <a href="https://example.org">HTML</a>.</p>',
195 "additional_descriptions": [
196 {
197 "description": "<p>Remote method.</p>",
198 "type": {"id": "methods"},
199 }
200 ],
201 "identifiers": [
202 {
203 "identifier": (
204 "https://w3id.org/changes/4/aldrovandi/itm/40/ob00/1"
205 ),
206 "scheme": "url",
207 }
208 ],
209 "related_identifiers": [
210 {
211 "identifier": "10.1234/example",
212 "scheme": "doi",
213 "relation_type": {"id": "isdocumentedby"},
214 "resource_type": {"id": "publication-article"},
215 }
216 ],
217 "rights": [
218 {
219 "title": {"en": "Remote right"},
220 "link": "https://example.org/right",
221 }
222 ],
223 "publisher": "Zenodo",
224 },
225 "custom_fields": {"project:code": "CHANGES"},
226 }
229def _run_prepare(drafts_path: Path, output_dir: Path, real_kg: Graph) -> Path:
230 with patch(
231 "changes_metadata_manager.patch.creator_names.load_kg", return_value=real_kg
232 ):
233 return prepare_creator_name_patches(
234 drafts_path, DATA_DIR / "kg.ttl", output_dir
235 )
238@patch("changes_metadata_manager.patch.creator_names.time.sleep")
239@patch("changes_metadata_manager.patch.creator_names.fetch_record")
240def test_prepare_writes_payload_manifest_and_reuses_cache(
241 mock_fetch, mock_sleep, tmp_path, real_kg
242):
243 drafts_path = _write_drafts(tmp_path, [_draft(20420559)])
244 output_dir = tmp_path / "prepared"
245 record = _record("40", "raw", METADATA_CREATORS)
246 mock_fetch.return_value = (record, False)
247 expected_creators = [VALENTINA, *METADATA_CREATORS]
248 expected_manifest = [
249 {
250 "record_id": 20420559,
251 "entity_ids": ["40"],
252 "stage": "raw",
253 "missing_creators": [
254 {
255 "name": "Valentina Alena Girelli",
256 "orcid": "0000-0001-9257-9803",
257 }
258 ],
259 "status": "would_patch",
260 "payload_file": "20420559.yaml",
261 }
262 ]
264 manifest_path = _run_prepare(drafts_path, output_dir, real_kg)
266 assert json.loads(manifest_path.read_text()) == expected_manifest
267 assert yaml.safe_load((output_dir / "20420559.yaml").read_text()) == (
268 _expected_payload(expected_creators)
269 )
270 assert {path.name for path in output_dir.iterdir()} == {
271 MANIFEST_FILENAME,
272 "20420559.yaml",
273 }
274 assert "secret-token" not in manifest_path.read_text()
275 assert "secret-token" not in (output_dir / "20420559.yaml").read_text()
277 second_manifest_path = _run_prepare(drafts_path, output_dir, real_kg)
279 assert json.loads(second_manifest_path.read_text()) == expected_manifest
280 assert mock_fetch.call_count == 1
281 assert mock_sleep.call_args_list == [call(2)]
284@patch("changes_metadata_manager.patch.creator_names.time.sleep")
285@patch("changes_metadata_manager.patch.prepared_updates.publish_draft")
286@patch("changes_metadata_manager.patch.prepared_updates.update_draft")
287@patch("changes_metadata_manager.patch.prepared_updates.create_edit_draft")
288@patch("changes_metadata_manager.patch.creator_names.fetch_record")
289def test_apply_uses_prepared_payload_without_refetching(
290 mock_fetch,
291 mock_create,
292 mock_update,
293 mock_publish,
294 mock_sleep,
295 tmp_path,
296 real_kg,
297):
298 drafts_path = _write_drafts(tmp_path, [_draft(20420559)])
299 output_dir = tmp_path / "prepared"
300 record = _record("40", "raw", METADATA_CREATORS)
301 mock_fetch.return_value = (record, False)
302 expected_payload = _expected_payload([VALENTINA, *METADATA_CREATORS])
303 _run_prepare(drafts_path, output_dir, real_kg)
305 with ZenodoRecordCache(tmp_path / CACHE_FILENAME) as cache:
306 assert cache.get("https://zenodo.org/api", "20420559") == (record, False)
308 log_path = apply_creator_name_patches(drafts_path, output_dir)
310 mock_fetch.assert_called_once()
311 mock_create.assert_called_once_with(
312 "https://zenodo.org/api",
313 "20420559",
314 "secret-token",
315 "changes-metadata-manager/1.0.0",
316 )
317 mock_update.assert_called_once_with(
318 "https://zenodo.org/api",
319 "20420559",
320 "secret-token",
321 "changes-metadata-manager/1.0.0",
322 expected_payload,
323 )
324 mock_publish.assert_called_once_with(
325 "https://zenodo.org/api",
326 "20420559",
327 "secret-token",
328 "changes-metadata-manager/1.0.0",
329 )
330 assert json.loads(log_path.read_text()) == [
331 {"record_id": 20420559, "status": "patched"}
332 ]
333 with ZenodoRecordCache(tmp_path / CACHE_FILENAME) as cache:
334 assert cache.get("https://zenodo.org/api", "20420559") is None
336 apply_creator_name_patches(drafts_path, output_dir)
338 assert mock_create.call_count == 1
339 assert mock_update.call_count == 1
340 assert mock_publish.call_count == 1
341 assert mock_sleep.call_args_list == [call(2), call(2)]
344@patch("changes_metadata_manager.patch.creator_names.time.sleep")
345@patch("changes_metadata_manager.patch.creator_names.fetch_record")
346def test_prepare_uses_entity_ids_from_uploaded_zip(
347 mock_fetch, mock_sleep, tmp_path, real_kg
348):
349 current_creators = [
350 ALICE,
351 FEDERICA,
352 FRANCESCA,
353 MARIA,
354 *METADATA_CREATORS,
355 ]
356 drafts_path = _write_drafts(
357 tmp_path,
358 [
359 _draft(
360 20437073,
361 folder_names=(
362 "S6-74a-ISPC_Linum_usitatissimum_L",
363 "S6-74b-ISPC-Orchis_morio_L",
364 "S6-74c-DBC_ButomusUmbellatusL",
365 "S6-74d-ISPC_Daphne_mezereum_L",
366 "S6-74e-ISPC_Primula_veris_L",
367 ),
368 )
369 ],
370 )
371 output_dir = tmp_path / "prepared"
372 mock_fetch.return_value = (
373 _record("74a", "dchoo", current_creators),
374 False,
375 )
377 manifest_path = _run_prepare(drafts_path, output_dir, real_kg)
379 assert json.loads(manifest_path.read_text()) == [
380 {
381 "record_id": 20437073,
382 "entity_ids": ["74a", "74b", "74c", "74d", "74e"],
383 "stage": "dchoo",
384 "missing_creators": [
385 {
386 "name": "Rachele Manganelli Del Fà",
387 "orcid": "0000-0002-4767-5684",
388 }
389 ],
390 "status": "would_patch",
391 "payload_file": "20437073.yaml",
392 }
393 ]
394 expected_payload = _expected_payload(
395 [ALICE, FEDERICA, FRANCESCA, MARIA, RACHELE, *METADATA_CREATORS]
396 )
397 expected_payload["metadata"]["identifiers"] = [
398 {
399 "identifier": ("https://w3id.org/changes/4/aldrovandi/itm/74a/ob00/1"),
400 "scheme": "url",
401 }
402 ]
403 assert yaml.safe_load((output_dir / "20437073.yaml").read_text()) == (
404 expected_payload
405 )
408@patch("changes_metadata_manager.patch.creator_names.time.sleep")
409@patch("changes_metadata_manager.patch.creator_names.fetch_record")
410def test_prepare_reports_blocked_and_correct_records(
411 mock_fetch, mock_sleep, tmp_path, real_kg
412):
413 unrelated_creators = deepcopy(METADATA_CREATORS)
414 unrelated_creators[0]["role"]["id"] = "researcher"
415 expected_creators = [VALENTINA, *METADATA_CREATORS]
416 drafts_path = _write_drafts(
417 tmp_path,
418 [
419 _draft(20420559),
420 _draft(20436931, folder_names=("S6-105-Test",)),
421 _draft(20420560),
422 ],
423 )
424 output_dir = tmp_path / "prepared"
425 mock_fetch.side_effect = [
426 (_record("40", "raw", METADATA_CREATORS), True),
427 (_record("105", "raw", unrelated_creators), False),
428 (_record("40", "raw", expected_creators), False),
429 ]
431 manifest_path = _run_prepare(drafts_path, output_dir, real_kg)
433 assert json.loads(manifest_path.read_text()) == [
434 {
435 "record_id": 20420559,
436 "entity_ids": ["40"],
437 "stage": "raw",
438 "missing_creators": [
439 {
440 "name": "Valentina Alena Girelli",
441 "orcid": "0000-0001-9257-9803",
442 }
443 ],
444 "status": "blocked",
445 "reason": "An edit draft already exists",
446 },
447 {
448 "record_id": 20436931,
449 "entity_ids": ["105"],
450 "stage": "raw",
451 "missing_creators": [
452 {
453 "name": "Rachele Manganelli Del Fà",
454 "orcid": "0000-0002-4767-5684",
455 }
456 ],
457 "status": "blocked",
458 "reason": "Creator differences are not limited to missing creators",
459 },
460 {
461 "record_id": 20420560,
462 "entity_ids": ["40"],
463 "stage": "raw",
464 "missing_creators": [],
465 "status": "already_correct",
466 },
467 ]
468 assert {path.name for path in output_dir.iterdir()} == {MANIFEST_FILENAME}
471@patch("changes_metadata_manager.patch.creator_names.time.sleep")
472@patch("changes_metadata_manager.patch.creator_names.fetch_record")
473def test_prepare_does_not_cache_http_errors(mock_fetch, mock_sleep, tmp_path, real_kg):
474 drafts_path = _write_drafts(tmp_path, [_draft(20420559)])
475 output_dir = tmp_path / "prepared"
476 mock_fetch.side_effect = requests.HTTPError("500 Server Error")
478 manifest_path = _run_prepare(drafts_path, output_dir, real_kg)
479 second_manifest_path = _run_prepare(drafts_path, output_dir, real_kg)
481 expected_manifest = [
482 {
483 "record_id": 20420559,
484 "status": "error",
485 "error": "500 Server Error",
486 }
487 ]
488 assert json.loads(manifest_path.read_text()) == expected_manifest
489 assert json.loads(second_manifest_path.read_text()) == expected_manifest
490 assert mock_fetch.call_count == 2
491 assert mock_sleep.call_args_list == [call(2), call(2)]
492 assert "secret-token" not in second_manifest_path.read_text()
495def test_prepare_rejects_unmanaged_output_files(tmp_path):
496 drafts_path = _write_drafts(tmp_path, [_draft(20420559)])
497 output_dir = tmp_path / "prepared"
498 output_dir.mkdir()
499 unrelated_path = output_dir / "notes.txt"
500 unrelated_path.write_text("keep me")
502 with pytest.raises(ValueError, match="Output directory contains unmanaged files"):
503 prepare_creator_name_patches(drafts_path, DATA_DIR / "kg.ttl", output_dir)
505 assert unrelated_path.read_text() == "keep me"
508@patch("changes_metadata_manager.patch.creator_names.time.sleep")
509@patch("changes_metadata_manager.patch.prepared_updates.publish_draft")
510@patch("changes_metadata_manager.patch.prepared_updates.update_draft")
511@patch("changes_metadata_manager.patch.prepared_updates.create_edit_draft")
512def test_apply_logs_errors_and_continues(
513 mock_create, mock_update, mock_publish, mock_sleep, tmp_path
514):
515 drafts_path = _write_drafts(tmp_path, [_draft(20420559), _draft(20436931)])
516 output_dir = tmp_path / "prepared"
517 output_dir.mkdir()
518 payload = _expected_payload([VALENTINA, *METADATA_CREATORS])
519 manifest = [
520 {
521 "record_id": 20420559,
522 "status": "would_patch",
523 "payload_file": "20420559.yaml",
524 },
525 {
526 "record_id": 20436931,
527 "status": "would_patch",
528 "payload_file": "20436931.yaml",
529 },
530 ]
531 (output_dir / MANIFEST_FILENAME).write_text(json.dumps(manifest))
532 for entry in manifest:
533 (output_dir / entry["payload_file"]).write_text(
534 yaml.safe_dump(payload, sort_keys=False)
535 )
536 with ZenodoRecordCache(tmp_path / CACHE_FILENAME) as cache:
537 cache.set("https://zenodo.org/api", "20420559", {"id": 1}, False)
538 cache.set("https://zenodo.org/api", "20436931", {"id": 2}, False)
539 mock_create.side_effect = [requests.HTTPError("500 Server Error"), {}]
541 log_path = apply_creator_name_patches(drafts_path, output_dir)
543 assert json.loads(log_path.read_text()) == [
544 {
545 "record_id": 20420559,
546 "status": "error",
547 "error": "500 Server Error",
548 },
549 {"record_id": 20436931, "status": "patched"},
550 ]
551 assert mock_create.call_count == 2
552 mock_update.assert_called_once_with(
553 "https://zenodo.org/api",
554 "20436931",
555 "secret-token",
556 "changes-metadata-manager/1.0.0",
557 payload,
558 )
559 mock_publish.assert_called_once_with(
560 "https://zenodo.org/api",
561 "20436931",
562 "secret-token",
563 "changes-metadata-manager/1.0.0",
564 )
565 assert mock_sleep.call_args_list == [call(2), call(2)]
566 with ZenodoRecordCache(tmp_path / CACHE_FILENAME) as cache:
567 assert cache.get("https://zenodo.org/api", "20420559") is None
568 assert cache.get("https://zenodo.org/api", "20436931") is None
569 assert "secret-token" not in log_path.read_text()
571 apply_creator_name_patches(drafts_path, output_dir)
573 assert mock_create.call_count == 2
574 assert mock_update.call_count == 1
575 assert mock_publish.call_count == 1