Spaces:
Sleeping
Sleeping
| """ | |
| Tests for DOI verification via CrossRef API. | |
| """ | |
| from unittest.mock import Mock, patch | |
| import pytest | |
| import requests | |
| from verify_citations.verifier import CitationVerifier | |
| def _make_crossref_response(status_code=200, message=None): | |
| """Helper to create a mock CrossRef API response.""" | |
| resp = Mock() | |
| resp.status_code = status_code | |
| if message is not None: | |
| resp.json.return_value = {'message': message} | |
| return resp | |
| def _make_entry(**overrides): | |
| """Helper to create a BibTeX entry dict.""" | |
| entry = { | |
| 'ID': 'test2024', | |
| 'title': 'Implicit-Explicit numerical schemes for jump-diffusion processes', | |
| 'author': 'Briani, M. and Natalini, R. and Russo, G.', | |
| 'year': '2007', | |
| 'journal': 'Calcolo', | |
| 'volume': '44', | |
| 'doi': '10.1007/s10092-007-0128-x', | |
| } | |
| entry.update(overrides) | |
| return entry | |
| class TestCheckDoiCrossref: | |
| """Tests for _check_doi_crossref method.""" | |
| def test_doi_verified_all_fields_match(self): | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| 'published': {'date-parts': [[2007]]}, | |
| 'volume': '44', | |
| 'container-title': ['Calcolo'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is True | |
| assert 'verified' in msg.lower() | |
| assert details is None | |
| def test_doi_title_mismatch(self): | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['A completely different paper about topology'], | |
| 'published': {'date-parts': [[2007]]}, | |
| 'volume': '44', | |
| 'container-title': ['Calcolo'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is False | |
| assert 'mismatch' in msg.lower() | |
| assert details is not None | |
| assert details['title_match'] is False | |
| assert any(field == 'title' for field, _, _ in details['mismatches']) | |
| def test_doi_year_mismatch(self): | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| 'published': {'date-parts': [[2010]]}, | |
| 'volume': '44', | |
| 'container-title': ['Calcolo'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is False | |
| assert 'year' in msg.lower() | |
| assert details is not None | |
| assert any(field == 'year' for field, _, _ in details['mismatches']) | |
| def test_doi_volume_mismatch(self): | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| 'published': {'date-parts': [[2007]]}, | |
| 'volume': '99', | |
| 'container-title': ['Calcolo'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is False | |
| assert 'volume' in msg.lower() | |
| def test_doi_journal_mismatch(self): | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| 'published': {'date-parts': [[2007]]}, | |
| 'volume': '44', | |
| 'container-title': ['Journal of Computational Physics'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is False | |
| assert 'journal' in msg.lower() | |
| def test_doi_multiple_mismatches(self): | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['Something entirely different'], | |
| 'published': {'date-parts': [[2020]]}, | |
| 'volume': '1', | |
| 'container-title': ['Nature'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is False | |
| assert details is not None | |
| mismatch_fields = [field for field, _, _ in details['mismatches']] | |
| assert 'title' in mismatch_fields | |
| assert 'year' in mismatch_fields | |
| def test_doi_not_found_404(self): | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| mock_resp = _make_crossref_response(404) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is False | |
| assert 'not found' in msg.lower() | |
| def test_doi_rate_limited_429(self): | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| mock_resp = _make_crossref_response(429) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is None | |
| assert 'rate limited' in msg.lower() | |
| def test_doi_server_error(self): | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| mock_resp = _make_crossref_response(500) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is None | |
| assert '500' in msg | |
| def test_doi_network_error(self): | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| with patch.object(verifier, '_make_request_with_retry', | |
| side_effect=requests.exceptions.ConnectionError("connection failed")): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is None | |
| assert 'could not verify' in msg.lower() | |
| def test_doi_no_title_from_crossref(self): | |
| """DOI resolves but CrossRef returns no title — comparison should be skipped.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': [], | |
| 'published': {'date-parts': [[2007]]}, | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| # No title to compare — should return None (skipped) | |
| assert correct is None | |
| assert 'skipped' in msg.lower() | |
| def test_crossref_mailto_appended_to_url(self): | |
| """Verify that crossref_mailto is included in the request URL.""" | |
| verifier = CitationVerifier(crossref_mailto='test@university.edu') | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| 'published': {'date-parts': [[2007]]}, | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp) as mock_req: | |
| verifier._check_doi_crossref(entry) | |
| called_url = mock_req.call_args[0][1] | |
| assert 'mailto=test@university.edu' in called_url | |
| def test_crossref_mailto_not_appended_when_empty(self): | |
| verifier = CitationVerifier(crossref_mailto='') | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp) as mock_req: | |
| verifier._check_doi_crossref(entry) | |
| called_url = mock_req.call_args[0][1] | |
| assert 'mailto' not in called_url | |
| def test_doi_uses_published_print_fallback(self): | |
| """Year extraction should fall back to published-print if published is absent.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| 'published-print': {'date-parts': [[2007]]}, | |
| 'volume': '44', | |
| 'container-title': ['Calcolo'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is True | |
| def test_doi_uses_issued_fallback(self): | |
| """Year extraction should fall back to issued if published and published-print are absent.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| 'issued': {'date-parts': [[2007]]}, | |
| 'volume': '44', | |
| 'container-title': ['Calcolo'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is True | |
| def test_doi_booktitle_used_when_no_journal(self): | |
| """Should compare booktitle against container-title when journal is absent.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry(journal='', booktitle='NeurIPS 2023') | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| 'published': {'date-parts': [[2007]]}, | |
| 'container-title': ['NeurIPS 2023'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is True | |
| def test_doi_curly_braces_stripped(self): | |
| """Curly braces in BibTeX fields should not affect comparison.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry(title='{Implicit}-{Explicit} numerical schemes') | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes'], | |
| 'published': {'date-parts': [[2007]]}, | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is True | |
| class TestBogusDoiDetection: | |
| """Tests for bogus/malformed DOI detection.""" | |
| def test_bogus_doi_values_detected(self, bogus_doi): | |
| verifier = CitationVerifier() | |
| entry = _make_entry(doi=bogus_doi) | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is None | |
| assert 'does not look like a valid DOI' in msg | |
| def test_malformed_doi_values_detected(self, malformed_doi): | |
| verifier = CitationVerifier() | |
| entry = _make_entry(doi=malformed_doi) | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| assert correct is None | |
| assert 'does not look like a valid DOI' in msg | |
| def test_valid_doi_not_flagged_as_bogus(self): | |
| """A properly formatted DOI should pass the bogus check and reach the API.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry(doi='10.1007/s10092-007-0128-x') | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_doi_crossref(entry) | |
| # Should NOT be flagged as bogus — should reach the API | |
| assert 'does not look like a valid DOI' not in msg | |
| class TestCrossrefDoiSearch: | |
| """Tests for CrossRef DOI search in _check_findable_online.""" | |
| def test_doi_search_prioritized_first(self): | |
| """CrossRef DOI search should be tried before arXiv and other sources.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp) as mock_req: | |
| findable, url, logs = verifier._check_findable_online(entry) | |
| assert findable is True | |
| assert url == f"https://doi.org/{entry['doi']}" | |
| # Should have only made one request (CrossRef), not fallen through to arXiv | |
| assert mock_req.call_count == 1 | |
| called_url = mock_req.call_args[0][1] | |
| assert 'api.crossref.org/works/' in called_url | |
| def test_doi_search_returns_doi_org_url(self): | |
| """Search URL should be a doi.org link when found via CrossRef.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry(doi='10.2307/2951600') | |
| cr_message = {'title': ['Stochastic differential utility']} | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| findable, url, logs = verifier._check_findable_online(entry) | |
| assert findable is True | |
| assert url == 'https://doi.org/10.2307/2951600' | |
| def test_doi_search_falls_through_on_404(self): | |
| """If DOI not found in CrossRef, should fall through to other sources.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry() # no eprint/url, so arXiv-by-ID is skipped | |
| mock_404 = _make_crossref_response(404) | |
| mock_200_semantic = Mock() | |
| mock_200_semantic.status_code = 200 | |
| mock_200_semantic.json.return_value = { | |
| 'data': [{'title': 'Implicit-Explicit numerical schemes for jump-diffusion processes', | |
| 'paperId': 'abc123'}] | |
| } | |
| # CrossRef 404 → arXiv title search 404 → ACL 404 → Semantic Scholar 200 | |
| with patch.object(verifier, '_make_request_with_retry', | |
| side_effect=[mock_404, mock_404, mock_404, mock_200_semantic]): | |
| findable, url, logs = verifier._check_findable_online(entry) | |
| assert findable is True | |
| assert 'semanticscholar.org' in url | |
| def test_doi_search_falls_through_on_429(self): | |
| """If CrossRef rate limits, should fall through to other sources.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry() # no eprint/url, so arXiv-by-ID is skipped | |
| mock_429 = _make_crossref_response(429) | |
| mock_404 = _make_crossref_response(404) | |
| mock_200_semantic = Mock() | |
| mock_200_semantic.status_code = 200 | |
| mock_200_semantic.json.return_value = { | |
| 'data': [{'title': 'Implicit-Explicit numerical schemes for jump-diffusion processes', | |
| 'paperId': 'abc123'}] | |
| } | |
| # CrossRef 429 → arXiv title search 404 → ACL 404 → Semantic Scholar 200 | |
| with patch.object(verifier, '_make_request_with_retry', | |
| side_effect=[mock_429, mock_404, mock_404, mock_200_semantic]): | |
| findable, url, logs = verifier._check_findable_online(entry) | |
| assert findable is True | |
| log_text = ' '.join(logs) | |
| assert '429' in log_text | |
| def test_doi_search_skipped_for_invalid_doi(self): | |
| """Entries with malformed DOIs should not try CrossRef search.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry(doi='not-a-doi') | |
| # Should not call CrossRef at all; will fall through to arXiv etc. | |
| mock_404 = _make_crossref_response(404) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_404) as mock_req: | |
| verifier._check_findable_online(entry) | |
| # First call should NOT be to crossref (should be arXiv) | |
| if mock_req.call_count > 0: | |
| first_url = mock_req.call_args_list[0][0][1] | |
| assert 'crossref.org' not in first_url | |
| def test_doi_search_skipped_when_no_doi(self): | |
| """Entries without a DOI should skip CrossRef search.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| del entry['doi'] | |
| mock_404 = _make_crossref_response(404) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_404) as mock_req: | |
| verifier._check_findable_online(entry) | |
| if mock_req.call_count > 0: | |
| first_url = mock_req.call_args_list[0][0][1] | |
| assert 'crossref.org' not in first_url | |
| def test_doi_search_uses_crossref_mailto(self): | |
| """CrossRef DOI search should include mailto when configured.""" | |
| verifier = CitationVerifier(crossref_mailto='me@university.edu') | |
| entry = _make_entry() | |
| cr_message = {'title': ['Some paper']} | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp) as mock_req: | |
| verifier._check_findable_online(entry) | |
| called_url = mock_req.call_args_list[0][0][1] | |
| assert 'mailto=me@university.edu' in called_url | |
| def test_doi_search_handles_network_error(self): | |
| """Network errors during CrossRef DOI search should fall through gracefully.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry() # no eprint/url, so arXiv-by-ID is skipped | |
| mock_200_semantic = Mock() | |
| mock_200_semantic.status_code = 200 | |
| mock_200_semantic.json.return_value = { | |
| 'data': [{'title': 'Implicit-Explicit numerical schemes for jump-diffusion processes', | |
| 'paperId': 'abc123'}] | |
| } | |
| # CrossRef error → arXiv title search 404 → ACL 404 → Semantic Scholar 200 | |
| with patch.object(verifier, '_make_request_with_retry', | |
| side_effect=[ | |
| requests.exceptions.ConnectionError("timeout"), | |
| Mock(status_code=404), # arXiv title search | |
| Mock(status_code=404), # ACL | |
| mock_200_semantic, # Semantic Scholar | |
| ]): | |
| findable, url, logs = verifier._check_findable_online(entry) | |
| assert findable is True | |
| log_text = ' '.join(logs) | |
| assert 'Error resolving DOI' in log_text | |
| class TestCrossrefMetadata: | |
| """Tests for _check_metadata with doi.org URLs.""" | |
| def test_metadata_verified_via_crossref(self): | |
| """Title and authors should be verified against CrossRef data.""" | |
| verifier = CitationVerifier() | |
| entry = { | |
| 'ID': 'dufeps1992', | |
| 'title': 'Stochastic differential utility', | |
| 'author': 'Duffie, D. and Epstein, L.G.', | |
| 'year': '1992', | |
| 'journal': 'Econometrica', | |
| 'doi': '10.2307/2951600', | |
| } | |
| cr_message = { | |
| 'title': ['Stochastic Differential Utility'], | |
| 'author': [ | |
| {'given': 'Darrell', 'family': 'Duffie'}, | |
| {'given': 'Larry G.', 'family': 'Epstein'}, | |
| ], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_metadata( | |
| entry, 'https://doi.org/10.2307/2951600') | |
| assert correct is True | |
| assert 'verified' in msg.lower() | |
| def test_metadata_title_mismatch_via_crossref(self): | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['A totally different paper'], | |
| 'author': [ | |
| {'given': 'M.', 'family': 'Briani'}, | |
| {'given': 'R.', 'family': 'Natalini'}, | |
| {'given': 'G.', 'family': 'Russo'}, | |
| ], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_metadata( | |
| entry, f"https://doi.org/{entry['doi']}") | |
| assert correct is False | |
| assert details is not None | |
| assert details['title_match'] is False | |
| def test_metadata_author_mismatch_via_crossref(self): | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| 'author': [ | |
| {'given': 'John', 'family': 'Smith'}, | |
| {'given': 'Jane', 'family': 'Doe'}, | |
| ], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_metadata( | |
| entry, f"https://doi.org/{entry['doi']}") | |
| assert correct is False | |
| assert 'author' in msg.lower() | |
| def test_metadata_crossref_institutional_author(self): | |
| """Institutional authors (using 'name' instead of given/family) should be handled.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry(author='Council, National Research') | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| 'author': [ | |
| {'name': 'National Research Council'}, | |
| ], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_metadata( | |
| entry, f"https://doi.org/{entry['doi']}") | |
| # Should not crash; details should have author info | |
| assert correct is not None or correct is None # no crash | |
| log_text = ' '.join(logs) | |
| assert 'National Research Council' in log_text | |
| def test_metadata_crossref_uses_mailto(self): | |
| """CrossRef metadata check should include mailto in API URL.""" | |
| verifier = CitationVerifier(crossref_mailto='me@uni.edu') | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| 'author': [{'given': 'M.', 'family': 'Briani'}], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp) as mock_req: | |
| verifier._check_metadata(entry, f"https://doi.org/{entry['doi']}") | |
| called_url = mock_req.call_args[0][1] | |
| assert 'mailto=me@uni.edu' in called_url | |
| def test_metadata_et_al_via_crossref(self): | |
| """Entries with 'and others' should use the et-al matching logic.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry(author='Briani, M. and Natalini, R. and others') | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| 'author': [ | |
| {'given': 'M.', 'family': 'Briani'}, | |
| {'given': 'R.', 'family': 'Natalini'}, | |
| {'given': 'G.', 'family': 'Russo'}, | |
| ], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_metadata( | |
| entry, f"https://doi.org/{entry['doi']}") | |
| # Briani is found in the online list, so should pass | |
| assert correct is True | |
| class TestUserExamples: | |
| """Tests based on the user's reported problem entries.""" | |
| def test_duffie_epstein_1992_found_via_doi(self): | |
| """Duffie & Epstein (1992) should be found via CrossRef DOI, not arXiv.""" | |
| verifier = CitationVerifier() | |
| entry = { | |
| 'ID': 'dufeps1992', | |
| 'title': 'Stochastic differential utility', | |
| 'author': 'Duffie, D. and L.G. Epstein', | |
| 'journal': 'Econometrica', | |
| 'volume': '60', | |
| 'pages': '353-394', | |
| 'year': '1992', | |
| 'doi': '10.2307/2951600', | |
| } | |
| cr_message = { | |
| 'title': ['Stochastic Differential Utility'], | |
| 'author': [ | |
| {'given': 'Darrell', 'family': 'Duffie'}, | |
| {'given': 'Larry G.', 'family': 'Epstein'}, | |
| ], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| findable, url, logs = verifier._check_findable_online(entry) | |
| assert findable is True | |
| assert url == 'https://doi.org/10.2307/2951600' | |
| log_text = ' '.join(logs) | |
| assert 'CrossRef' in log_text | |
| def test_duffie_epstein_1992_metadata_matches(self): | |
| """Duffie & Epstein metadata should match via CrossRef (not arXiv mismatch).""" | |
| verifier = CitationVerifier() | |
| entry = { | |
| 'ID': 'dufeps1992', | |
| 'title': 'Stochastic differential utility', | |
| 'author': 'Duffie, D. and L.G. Epstein', | |
| 'journal': 'Econometrica', | |
| 'volume': '60', | |
| 'pages': '353-394', | |
| 'year': '1992', | |
| 'doi': '10.2307/2951600', | |
| } | |
| cr_message = { | |
| 'title': ['Stochastic Differential Utility'], | |
| 'author': [ | |
| {'given': 'Darrell', 'family': 'Duffie'}, | |
| {'given': 'Larry G.', 'family': 'Epstein'}, | |
| ], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_metadata( | |
| entry, 'https://doi.org/10.2307/2951600') | |
| assert correct is True | |
| assert 'verified' in msg.lower() | |
| def test_golosov_2014_found_via_doi(self): | |
| """Golosov et al. (2014) should be found via CrossRef DOI, not Semantic Scholar.""" | |
| verifier = CitationVerifier() | |
| entry = { | |
| 'ID': 'golosov2014', | |
| 'title': 'Optimal Taxes on Fossil Fuels in General Equilibrium', | |
| 'author': 'Golosov, M. and J. Hassler and P. Krusell and A. Tsyvinski', | |
| 'journal': 'Econometrica', | |
| 'volume': '82', | |
| 'number': '1', | |
| 'pages': '41-88', | |
| 'year': '2014', | |
| 'doi': '10.3982/ECTA10217', | |
| } | |
| cr_message = { | |
| 'title': ['Optimal Taxes on Fossil Fuel in General Equilibrium'], | |
| 'author': [ | |
| {'given': 'Mikhail', 'family': 'Golosov'}, | |
| {'given': 'John', 'family': 'Hassler'}, | |
| {'given': 'Per', 'family': 'Krusell'}, | |
| {'given': 'Aleh', 'family': 'Tsyvinski'}, | |
| ], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| findable, url, logs = verifier._check_findable_online(entry) | |
| assert findable is True | |
| assert url == 'https://doi.org/10.3982/ECTA10217' | |
| def test_golosov_2014_metadata_matches(self): | |
| """Golosov et al. metadata should match via CrossRef.""" | |
| verifier = CitationVerifier() | |
| entry = { | |
| 'ID': 'golosov2014', | |
| 'title': 'Optimal Taxes on Fossil Fuels in General Equilibrium', | |
| 'author': 'Golosov, M. and J. Hassler and P. Krusell and A. Tsyvinski', | |
| 'journal': 'Econometrica', | |
| 'volume': '82', | |
| 'year': '2014', | |
| 'doi': '10.3982/ECTA10217', | |
| } | |
| cr_message = { | |
| 'title': ['Optimal Taxes on Fossil Fuel in General Equilibrium'], | |
| 'author': [ | |
| {'given': 'Mikhail', 'family': 'Golosov'}, | |
| {'given': 'John', 'family': 'Hassler'}, | |
| {'given': 'Per', 'family': 'Krusell'}, | |
| {'given': 'Aleh', 'family': 'Tsyvinski'}, | |
| ], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| correct, msg, details, logs = verifier._check_metadata( | |
| entry, 'https://doi.org/10.3982/ECTA10217') | |
| assert correct is True | |
| def test_full_pipeline_duffie_epstein(self): | |
| """Full verify_citation pipeline for Duffie & Epstein should pass.""" | |
| verifier = CitationVerifier() | |
| entry = { | |
| 'ID': 'dufeps1992', | |
| 'title': 'Stochastic differential utility', | |
| 'author': 'Duffie, D. and L.G. Epstein', | |
| 'journal': 'Econometrica', | |
| 'volume': '60', | |
| 'year': '1992', | |
| 'doi': '10.2307/2951600', | |
| } | |
| cr_message = { | |
| 'title': ['Stochastic Differential Utility'], | |
| 'author': [ | |
| {'given': 'Darrell', 'family': 'Duffie'}, | |
| {'given': 'Larry G.', 'family': 'Epstein'}, | |
| ], | |
| 'published': {'date-parts': [[1992]]}, | |
| 'volume': '60', | |
| 'container-title': ['Econometrica'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| result = verifier.verify_citation(entry) | |
| assert result['status'] == 'verified' | |
| assert result['checks']['findable_online'] is True | |
| assert result['checks']['metadata_correct'] is True | |
| assert result['checks']['doi_correct'] is True | |
| class TestVerifyCitationDoiIntegration: | |
| """Tests for DOI check integration in verify_citation.""" | |
| def test_doi_correct_in_checks_dict(self): | |
| """verify_citation result should include doi_correct key.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| result = verifier.verify_citation(entry) | |
| assert 'doi_correct' in result['checks'] | |
| def test_doi_details_in_result(self): | |
| """verify_citation result should include doi_details when there's a DOI mismatch.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['A completely wrong paper'], | |
| 'published': {'date-parts': [[2020]]}, | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| result = verifier.verify_citation(entry) | |
| assert result['checks']['doi_correct'] is False | |
| assert result.get('doi_details') is not None | |
| def test_no_doi_field_skips_check(self): | |
| """Entries without a DOI should skip the CrossRef check entirely.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| del entry['doi'] | |
| result = verifier.verify_citation(entry) | |
| assert result['checks']['doi_correct'] is None | |
| def test_empty_doi_field_skips_check(self): | |
| """Entries with an empty DOI should skip the CrossRef check.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry(doi='') | |
| result = verifier.verify_citation(entry) | |
| assert result['checks']['doi_correct'] is None | |
| def test_doi_mismatch_sets_issues_found(self): | |
| """A DOI mismatch should set overall status to issues_found.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| # Mock _check_findable_online to return not found (simplifies the test) | |
| with patch.object(verifier, '_check_findable_online', return_value=(False, None, [])): | |
| cr_message = { | |
| 'title': ['Wrong paper entirely'], | |
| 'published': {'date-parts': [[2020]]}, | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| result = verifier.verify_citation(entry) | |
| assert result['status'] == 'issues_found' | |
| def test_verbose_logs_include_crossref(self): | |
| """Verbose logs should contain CrossRef checking details.""" | |
| verifier = CitationVerifier() | |
| entry = _make_entry() | |
| cr_message = { | |
| 'title': ['Implicit-Explicit numerical schemes for jump-diffusion processes'], | |
| } | |
| mock_resp = _make_crossref_response(200, cr_message) | |
| with patch.object(verifier, '_make_request_with_retry', return_value=mock_resp): | |
| result = verifier.verify_citation(entry) | |
| log_text = ' '.join(result.get('verbose_logs', [])) | |
| assert 'crossref' in log_text.lower() | |