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

1# SPDX-FileCopyrightText: 2025-2026 Arcangelo Massari <arcangelo.massari@unibo.it> 

2# 

3# SPDX-License-Identifier: ISC 

4 

5import json 

6import zipfile 

7from copy import deepcopy 

8from pathlib import Path 

9from unittest.mock import call, patch 

10 

11import pytest 

12import requests 

13import yaml 

14from rdflib import Graph 

15 

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 

24 

25DATA_DIR = Path(__file__).parent.parent / "data" 

26 

27 

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 } 

45 

46 

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) 

68 

69 

70@pytest.fixture(scope="module") 

71def real_kg(): 

72 return load_kg(DATA_DIR / "kg.ttl") 

73 

74 

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 } 

88 

89 

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) 

103 

104 drafts_path = tmp_path / "drafts.json" 

105 drafts_path.write_text(json.dumps(serialized_drafts)) 

106 return drafts_path 

107 

108 

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 

117 

118 

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 } 

180 

181 

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 } 

227 

228 

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 ) 

236 

237 

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 ] 

263 

264 manifest_path = _run_prepare(drafts_path, output_dir, real_kg) 

265 

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() 

276 

277 second_manifest_path = _run_prepare(drafts_path, output_dir, real_kg) 

278 

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)] 

282 

283 

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) 

304 

305 with ZenodoRecordCache(tmp_path / CACHE_FILENAME) as cache: 

306 assert cache.get("https://zenodo.org/api", "20420559") == (record, False) 

307 

308 log_path = apply_creator_name_patches(drafts_path, output_dir) 

309 

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 

335 

336 apply_creator_name_patches(drafts_path, output_dir) 

337 

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)] 

342 

343 

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 ) 

376 

377 manifest_path = _run_prepare(drafts_path, output_dir, real_kg) 

378 

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 ) 

406 

407 

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 ] 

430 

431 manifest_path = _run_prepare(drafts_path, output_dir, real_kg) 

432 

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} 

469 

470 

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") 

477 

478 manifest_path = _run_prepare(drafts_path, output_dir, real_kg) 

479 second_manifest_path = _run_prepare(drafts_path, output_dir, real_kg) 

480 

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() 

493 

494 

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") 

501 

502 with pytest.raises(ValueError, match="Output directory contains unmanaged files"): 

503 prepare_creator_name_patches(drafts_path, DATA_DIR / "kg.ttl", output_dir) 

504 

505 assert unrelated_path.read_text() == "keep me" 

506 

507 

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"), {}] 

540 

541 log_path = apply_creator_name_patches(drafts_path, output_dir) 

542 

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() 

570 

571 apply_creator_name_patches(drafts_path, output_dir) 

572 

573 assert mock_create.call_count == 2 

574 assert mock_update.call_count == 1 

575 assert mock_publish.call_count == 1