mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
e5718cbf58
* fix: apply Chinese-aware title matching in the four index resolvers (#798) `chinese_literature_client.py` already carried a Chinese-aware `normalize_cn_title` / `has_cjk`, but the four index resolvers (Semantic Scholar / OpenAlex / Crossref / arXiv) read the ASCII-centric helpers in `_text_similarity.py`, where `.lower()` folds case but never width (P U+FF30 never reaches P U+0050) and `string.punctuation` contains none of `。`, `《》`, or U+3000. A real Chinese paper served by an index in a different-but-legitimate typesetting therefore missed on two paths: the DOI-keyed cross-check, which gates on the fuzzy ratio alone and scored a fullwidth spelling of the identical title at 0.625 (under the 0.70 floor) reporting a correct DOI as DOI_MISMATCH; and the title-fallback search, which requires ratio AND exact equality and so fell to `unresolvable`. Both feed the `*_unmatched` contamination signals, so a genuine paper could render as CONTAMINATED-TRIANGULATION-UNMATCHED. Promotes `has_cjk` / `normalize_cn_title` into `_text_similarity.py` byte-identical (the CJK client now re-imports rather than keeping a private copy, per the #128 anti-drift goal), adds the Chinese-aware form to `exact_normalized_title` as an additive third branch, and folds it into `_similarity` through the existing `max`. Both gated on BOTH sides carrying a Han ideograph, so every non-CJK verdict is provably unchanged — pinned by an oracle test restating the pre-fix formula in full. 31 new tests, each verified to fail against the pre-fix module. Full suite: 9255 passed, 3 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011qexY5ysaqaAyPp97byf4w * test: force the ratio-independence and non-destructiveness proofs (#798 review) Addresses the three requested changes on PR #799. 1. `_cn_titles_match` ratio-independence is now forced, not inferred, in the test that claims it. `test_legitimate_variants_match_despite_a_sub_threshold_ fuzzy_ratio` asserted the match on a pair the repaired `_similarity` scores 1.0, so a regression that ANDed the ratio back in as a necessary condition would still have passed. Its match assertions now run inside a `monkeypatch.context()` with `_similarity` replaced by a detonator, scoped so the ratio measurements above it still see the real function. A new `test_cn_titles_match_never_consults_the_fuzzy_ratio` adds the negative half under the same forced conditions, so the invariant cannot be satisfied by a helper that has stopped discriminating. The shared `_forbid_similarity` helper patches BOTH binding paths: the `_text_similarity` module attribute (a qualified call or lazy in-function import) and the client's own namespace (a module-level `from ... import _similarity`, already bound and blind to the first patch). Both styles were mutation-verified to trip it; before this change the named test passed the regression that the new one caught. 2. `test_ratio_never_lowered_off_the_cjk_path` asserted `>=` against the base ratio alone, so it passed a *raised* non-CJK score and never exercised the dotted-acronym branch. Renamed to `test_ratio_unchanged_off_the_cjk_path` and rewritten against a full `_pre_fix_similarity` oracle — the companion to the existing `_pre_fix_exact_normalized_title`, written out in full for the same anti-drift reason — asserting exact equality. Mutation-verified twice: one raising a base-branch score, one confined to the acronym branch; the old assertion caught neither. 3. "Byte-identical" corrected to "behaviorally equivalent" in the `normalize_cn_title` docstring and the CHANGELOG. The promotion hoists the wrapper/terminal-mark sets to module constants, precompiles the regex, and rewrites comments; behavioral equivalence is what the tests actually pin. CHANGELOG test count corrected 31 -> 32 and its oracle sentence updated to describe both oracles. Full suite: 9256 passed, 3 skipped (+1 test). The pre-existing `test_evidence_rows.py::test_gfm_bare_urls_emails_and_schemes_cannot_autolink` failure is unchanged and also fails on clean main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011qexY5ysaqaAyPp97byf4w --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1979 lines
81 KiB
Python
1979 lines
81 KiB
Python
#!/usr/bin/env python3
|
||
"""Tests for the Chinese-literature resolver client (#595).
|
||
|
||
Per `deep-research/references/chinese_literature_api_protocol.md`. Structure
|
||
mirrors `test_arxiv_client.py` (per-client unit suite) but uses the
|
||
transport-fixture discipline of `test_transport_fixture_citation_gate.py`: the
|
||
REAL `ChineseLiteratureClient` builds the URLs and parses the bodies, and a
|
||
URL-dispatch fake stands in for the socket. Every body is checked in under
|
||
`scripts/fixtures/transport_bodies/chinese_literature/` and is fully synthetic
|
||
(see that directory's README).
|
||
|
||
ZERO live network. The protocol doc's live examples are a record of a manual
|
||
verification; nothing here contacts doi.org, hdl.handle.net, or NCBI.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import http.client
|
||
import io
|
||
import sys
|
||
import traceback
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||
if str(REPO_ROOT / "scripts") not in sys.path:
|
||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||
|
||
FIXTURES = REPO_ROOT / "scripts" / "fixtures" / "transport_bodies" / "chinese_literature"
|
||
|
||
# Synthetic identifiers pinned to the fixture bodies (see the fixtures README):
|
||
# These suffixes are synthetic fixture namespaces. The 10.5555 prefix itself
|
||
# can contain real Crossref records, so tests make no claim about live minting.
|
||
ISTIC_DOI = "10.5555/ars.cn.istic.2026.0042"
|
||
CNKI_DOI = "10.5555/ars.cn.cnki.2026.0042"
|
||
FABRICATED_DOI = "10.5555/ars.cn.istic.9999.9999"
|
||
|
||
CN_TITLE = "合成引文核验闸的确定性评估方法研究"
|
||
CN_JOURNAL = "合成测试医学杂志"
|
||
|
||
# Injected through the client's documented extension point, so no test depends
|
||
# on the shipped seed table's contents.
|
||
TEST_JOURNAL_MAP = {
|
||
CN_JOURNAL: {
|
||
"issn": "0000-0000",
|
||
"nlm_ta": "Hecheng Ceshi Yi Xue Za Zhi",
|
||
"nlm_id": "999999901",
|
||
"display_name": CN_JOURNAL,
|
||
}
|
||
}
|
||
|
||
|
||
def _body(name: str) -> bytes:
|
||
return FIXTURES.joinpath(name).read_bytes()
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _no_real_sleep(monkeypatch):
|
||
"""Pacing must never burn wall-clock in CI. Patched on the client module so
|
||
a `time.sleep` that leaks in from elsewhere still fails loudly."""
|
||
monkeypatch.setattr("chinese_literature_client.time.sleep", lambda seconds: None)
|
||
|
||
|
||
def _client(**kwargs):
|
||
from chinese_literature_client import ChineseLiteratureClient
|
||
|
||
kwargs.setdefault("ncbi_email", "ars-tests@example.invalid")
|
||
return ChineseLiteratureClient(**kwargs)
|
||
|
||
|
||
class FakeTransport:
|
||
"""urlopen stand-in dispatching on the REAL URL the client built.
|
||
|
||
`routes` is an ordered list of `(predicate, action)`; predicates receive
|
||
`(urlsplit_result, parse_qs_dict)`. An unrouted URL is an immediate test
|
||
failure — the dispatch table doubles as an assertion that the client
|
||
contacted exactly the documented endpoints. AssertionError is not in the
|
||
client's degradation except-list, so it propagates instead of being
|
||
laundered into ChineseLiteratureUnavailable.
|
||
"""
|
||
|
||
def __init__(self, routes):
|
||
self.routes = routes
|
||
self.requests: list[str] = []
|
||
|
||
def __call__(self, req, timeout=None):
|
||
url = req.full_url
|
||
self.requests.append(url)
|
||
parsed = urllib.parse.urlsplit(url)
|
||
query = (
|
||
urllib.parse.parse_qs(
|
||
parsed.query, keep_blank_values=True, strict_parsing=True)
|
||
if parsed.query else {}
|
||
)
|
||
for predicate, action in self.routes:
|
||
if predicate(parsed, query):
|
||
return action(url)
|
||
raise AssertionError(f"client contacted an unrouted URL: {url}")
|
||
|
||
|
||
def _ok(body: bytes):
|
||
def action(url):
|
||
resp = MagicMock()
|
||
resp.status = 200
|
||
resp.headers = {}
|
||
resp.geturl.return_value = url
|
||
resp.read.return_value = body
|
||
resp.__enter__ = MagicMock(return_value=resp)
|
||
resp.__exit__ = MagicMock(return_value=None)
|
||
return resp
|
||
|
||
return action
|
||
|
||
|
||
def _http_error(code: int, body: bytes = b""):
|
||
def action(url):
|
||
raise urllib.error.HTTPError(
|
||
url=url, code=code, msg="synthetic", hdrs={}, fp=io.BytesIO(body),
|
||
)
|
||
|
||
return action
|
||
|
||
|
||
# Route predicates, written against host+path so an accidental endpoint change
|
||
# in the client fails routing rather than silently following along.
|
||
def _is_ra(parsed, query):
|
||
return parsed.netloc == "doi.org" and parsed.path.startswith("/doiRA/")
|
||
|
||
|
||
def _is_doi_resolve(parsed, query):
|
||
return parsed.netloc == "doi.org" and not parsed.path.startswith("/doiRA/")
|
||
|
||
|
||
def _is_handle(parsed, query):
|
||
return parsed.netloc == "hdl.handle.net" and parsed.path.startswith("/api/handles/")
|
||
|
||
|
||
def _is_esearch(parsed, query):
|
||
return parsed.path.endswith("/esearch.fcgi")
|
||
|
||
|
||
def _is_esummary(parsed, query):
|
||
return parsed.path.endswith("/esummary.fcgi")
|
||
|
||
|
||
# ---------------------------------------------------------------- pure helpers
|
||
|
||
|
||
def test_normalize_folds_the_three_measured_chinese_variants():
|
||
"""The shared #431 normalizer rejects three legitimate spellings of one
|
||
identical Chinese title (measured 2026-07-27: fullwidth 0.577/False, CJK
|
||
terminal punctuation 0.981/False, interior spaces 0.929/False). The
|
||
Chinese-aware normalizer must fold all three to one string."""
|
||
from chinese_literature_client import normalize_cn_title
|
||
|
||
canonical = normalize_cn_title("宫颈腺癌中ProEXC和PRMT5的表达及其临床意义")
|
||
assert normalize_cn_title("宫颈腺癌中ProEXC和PRMT5的表达及其临床意义") == canonical
|
||
assert normalize_cn_title("宫颈腺癌中ProEXC和PRMT5的表达及其临床意义。") == canonical
|
||
assert normalize_cn_title("宫颈腺癌中 ProEXC 和 PRMT5 的表达及其临床意义") == canonical
|
||
assert normalize_cn_title("《宫颈腺癌中ProEXC和PRMT5的表达及其临床意义》") == canonical
|
||
|
||
|
||
def test_fuzzy_similarity_alone_never_promotes_a_different_paper():
|
||
"""Han-character overlap gives unrelated Chinese papers a high baseline
|
||
ratio (0.510 measured between two genuinely different papers), so the
|
||
shared fuzzy ratio is not sufficient — exact equality after Chinese-aware
|
||
normalization is the rule."""
|
||
from chinese_literature_client import _cn_titles_match
|
||
|
||
a = "宫颈腺癌中ProEXC和PRMT5的表达及其临床意义"
|
||
b = "宫颈癌及癌前病变组织hTERC基因表达及其临床意义"
|
||
assert _cn_titles_match(a, a) is True
|
||
assert _cn_titles_match(b, a) is False
|
||
|
||
|
||
def _forbid_similarity(patcher):
|
||
"""Install a detonator over `_similarity` so any read of the fuzzy ratio
|
||
raises instead of quietly returning a passing verdict.
|
||
|
||
Patched on BOTH binding paths, because they fail differently: the
|
||
`_text_similarity` module attribute catches a qualified `ts._similarity(...)`
|
||
call or a lazy in-function import, while `chinese_literature_client`'s own
|
||
namespace catches a module-level `from _text_similarity import _similarity`
|
||
— already bound at import time, so it would never see the first patch.
|
||
Both styles are verified to trip this.
|
||
"""
|
||
import _text_similarity
|
||
import chinese_literature_client
|
||
|
||
def _detonate(*args, **kwargs):
|
||
raise AssertionError(
|
||
"_cn_titles_match consulted the fuzzy ratio; #431 excludes it in "
|
||
"both directions (not sufficient, and not safe as a necessary "
|
||
"condition either)"
|
||
)
|
||
|
||
patcher.setattr(_text_similarity, "_similarity", _detonate)
|
||
patcher.setattr(
|
||
chinese_literature_client, "_similarity", _detonate, raising=False
|
||
)
|
||
|
||
|
||
def test_legitimate_variants_match_despite_a_sub_threshold_fuzzy_ratio(monkeypatch):
|
||
"""Regression for a live-smoke near-miss: the shared 0.70 ratio scores a
|
||
legitimate FULLWIDTH spelling of the identical title at 0.577, so ANDing it
|
||
in as an extra necessary condition would veto a correct match and file a
|
||
real paper at P0 next to the word 'fabricated'. On CJK the ratio separates
|
||
almost nothing (0.510 unrelated vs 0.577 identical), so it is excluded."""
|
||
from difflib import SequenceMatcher
|
||
|
||
from _text_similarity import (
|
||
_TITLE_SIMILARITY_THRESHOLD,
|
||
_normalize_title,
|
||
_similarity,
|
||
)
|
||
from chinese_literature_client import _cn_titles_match
|
||
|
||
canonical = "宫颈腺癌中ProEXC和PRMT5的表达及其临床意义"
|
||
fullwidth = "宫颈腺癌中ProEXC和PRMT5的表达及其临床意义。"
|
||
unrelated = "宫颈癌及癌前病变组织hTERC基因表达及其临床意义"
|
||
|
||
# The premise, measured on the ASCII-centric BASE normalization that
|
||
# motivated excluding the ratio: an identical title scores 0.566 while an
|
||
# unrelated paper scores 0.510 — the floor separates almost nothing, and
|
||
# ANDing it in would veto a correct match.
|
||
base_ratio = lambda x, y: SequenceMatcher( # noqa: E731
|
||
None, _normalize_title(x), _normalize_title(y)
|
||
).ratio()
|
||
assert base_ratio(canonical, fullwidth) < _TITLE_SIMILARITY_THRESHOLD
|
||
assert base_ratio(canonical, fullwidth) - base_ratio(canonical, unrelated) < 0.10
|
||
|
||
# Since the CJK repair, the shared helper no longer CONTRADICTS that rule:
|
||
# the Chinese-aware normalization is folded into `_similarity` as a third
|
||
# form, so an identical title reaches 1.0 while the unrelated pair stays at
|
||
# its 0.510 baseline. It simply stopped being a landmine for the four index
|
||
# resolvers, whose DOI cross-check gates on it alone.
|
||
assert _similarity(canonical, fullwidth) == 1.0
|
||
assert _similarity(canonical, unrelated) < _TITLE_SIMILARITY_THRESHOLD
|
||
|
||
# ...and the Chinese-aware rule still matches the pair — proven with the
|
||
# ratio FORCED to fail, not merely observed while it happened to agree.
|
||
# That distinction is the whole point here: the repair lifts this pair to
|
||
# 1.0, so a regression that ANDed the ratio back in as a necessary
|
||
# condition would sail past an assertion that just called
|
||
# `_cn_titles_match` and looked at the answer. Scoped to a context so the
|
||
# measurements above still see the real `_similarity`.
|
||
with monkeypatch.context() as patcher:
|
||
_forbid_similarity(patcher)
|
||
assert _cn_titles_match(fullwidth, canonical) is True
|
||
assert _cn_titles_match(canonical, fullwidth) is True
|
||
|
||
|
||
def test_cn_titles_match_never_consults_the_fuzzy_ratio(monkeypatch):
|
||
"""The ratio-independence invariant in both directions, on one helper.
|
||
|
||
The sibling test above proves the POSITIVE half on the pair that motivated
|
||
the rule. This one adds the negative half under the same forced conditions,
|
||
so the invariant cannot be satisfied by a helper that has simply stopped
|
||
discriminating — `_cn_titles_match` must still reject a genuinely different
|
||
paper without the ratio existing at all.
|
||
|
||
#431 excludes the ratio in both directions: it is not sufficient (two
|
||
unrelated Chinese papers measure 0.510 on Han overlap alone) and not safe
|
||
as an extra necessary condition either (a legitimate fullwidth spelling of
|
||
an identical title measures below the 0.70 floor, so ANDing it in would
|
||
veto a correct match and file a real paper at P0 next to the word
|
||
"fabricated")."""
|
||
from chinese_literature_client import _cn_titles_match
|
||
|
||
_forbid_similarity(monkeypatch)
|
||
|
||
canonical = "宫颈腺癌中ProEXC和PRMT5的表达及其临床意义"
|
||
fullwidth = "宫颈腺癌中ProEXC和PRMT5的表达及其临床意义。"
|
||
unrelated = "宫颈癌及癌前病变组织hTERC基因表达及其临床意义"
|
||
|
||
# The positive verdict must survive without the ratio existing at all...
|
||
assert _cn_titles_match(fullwidth, canonical) is True
|
||
assert _cn_titles_match(canonical, fullwidth) is True
|
||
# ...and so must the negative one, so this cannot be satisfied by a helper
|
||
# that has stopped discriminating.
|
||
assert _cn_titles_match(unrelated, canonical) is False
|
||
|
||
|
||
def test_doi_keyed_exact_generic_title_can_match():
|
||
"""The generic-title veto is for identifier-free title searches. A real,
|
||
DOI-keyed work may legitimately be titled Editorial."""
|
||
from chinese_literature_client import _cn_titles_match
|
||
|
||
assert _cn_titles_match("Editorial", "Editorial") is True
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"left,right",
|
||
[
|
||
("ER+ 乳腺癌患者的预后分析", "ER- 乳腺癌患者的预后分析"),
|
||
("CD4+ T细胞计数与预后", "CD4− T细胞计数与预后"),
|
||
("生存率提高 4.5% 的临床意义", "生存率提高 45% 的临床意义"),
|
||
("剂量2²的研究", "剂量22的研究"),
|
||
("Straße治疗研究", "Strasse治疗研究"),
|
||
("治疗是否有效?", "治疗是否有效"),
|
||
("PD L1蛋白表达", "PDL 1蛋白表达"),
|
||
("P53蛋白表达", "p53蛋白表达"),
|
||
],
|
||
)
|
||
def test_normalizer_preserves_scientifically_meaningful_distinctions(left, right):
|
||
from chinese_literature_client import normalize_cn_title
|
||
|
||
assert normalize_cn_title(left) != normalize_cn_title(right)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"variant",
|
||
[
|
||
CN_TITLE + ".",
|
||
CN_TITLE + ".",
|
||
"“" + CN_TITLE + "”",
|
||
"‘" + CN_TITLE + "’",
|
||
],
|
||
)
|
||
def test_normalizer_folds_conventional_terminal_periods_and_outer_quotes(variant):
|
||
from chinese_literature_client import normalize_cn_title
|
||
|
||
assert normalize_cn_title(variant) == normalize_cn_title(CN_TITLE)
|
||
|
||
|
||
def test_applicability_gate_skips_non_chinese_citations():
|
||
"""Mirrors _run_arxiv's skip semantics — an English citation must be
|
||
`skipped`, never `unmatched`, so English corpora are untouched."""
|
||
client = _client()
|
||
assert client.is_applicable({"title": "Attention Is All You Need"}) is False
|
||
assert client.is_applicable({"title": CN_TITLE}) is True
|
||
assert client.is_applicable({"container_title": CN_JOURNAL}) is True
|
||
assert client.is_applicable({"title": "A Study", "language": "zh"}) is True
|
||
# A Chinese RA on the DOI is sufficient even with a romanized title.
|
||
assert client.is_applicable({"title": "A Study"}, ra="ISTIC") is True
|
||
|
||
|
||
def test_resolve_skips_non_chinese_without_touching_the_network():
|
||
"""An English citation must cost ZERO requests — including when it carries
|
||
a DOI. Establishing applicability from the registration agency would mean
|
||
one doi.org round-trip per English reference in every bibliography."""
|
||
client = _client()
|
||
with patch("chinese_literature_client._safe_urlopen", side_effect=AssertionError("no network")):
|
||
result = client.resolve({
|
||
"citation_key": "smith2020",
|
||
"title": "An English Paper",
|
||
"doi": "10.5555/english.2020.1",
|
||
})
|
||
assert result["status"] == "skipped"
|
||
assert result["queried_by"] is None
|
||
assert result["reason_code"] == "NOT_CHINESE_LITERATURE"
|
||
assert result["checklist_item"] is None
|
||
|
||
|
||
# ------------------------------------------------------- RA triage (3 branches)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"fixture,expected",
|
||
[
|
||
("ra_istic.json", "ISTIC"),
|
||
("ra_cnki.json", "CNKI"),
|
||
("ra_crossref.json", "Crossref"),
|
||
],
|
||
)
|
||
def test_ra_lookup_routes_all_three_agencies(fixture, expected):
|
||
transport = FakeTransport([(_is_ra, _ok(_body(fixture)))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
assert _client().ra_for(ISTIC_DOI) == expected
|
||
assert transport.requests == ["https://doi.org/doiRA/10.5555"]
|
||
|
||
|
||
def test_ra_lookup_returns_none_for_an_unknown_prefix():
|
||
"""The endpoint does not error on an unknown prefix: it answers 200 with a
|
||
`status` field in place of `RA` (verified 2026-07-27: `/doiRA/10.99999` ->
|
||
`[{"DOI": "10.99999", "status": "DOI does not exist"}]`). The missing RA
|
||
key must yield None, never a degradation or a fabricated agency name."""
|
||
transport = FakeTransport([(_is_ra, _ok(_body("ra_unknown_prefix.json")))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
assert _client().ra_for("10.99999/ars.cn.unknown.2026.1") is None
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"body",
|
||
[
|
||
b"{}",
|
||
b"[]",
|
||
b"null",
|
||
b'[{"DOI":"10.other","RA":"ISTIC"}]',
|
||
b'[{"DOI":"10.5555","RA":false}]',
|
||
b'[{"DOI":"10.5555"}]',
|
||
b'[{"DOI":"10.5555","RA":"ISTIC"},{"DOI":"10.5555","RA":"CNKI"}]',
|
||
],
|
||
)
|
||
def test_ra_lookup_parseable_malformed_shape_is_typed_unavailable(body):
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
transport = FakeTransport([(_is_ra, _ok(body))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().ra_for(ISTIC_DOI)
|
||
|
||
|
||
def test_ra_lookup_short_circuits_a_non_doi_without_a_request():
|
||
transport = FakeTransport([])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
assert _client().ra_for("not-a-doi") is None
|
||
assert transport.requests == []
|
||
|
||
|
||
def test_crossref_registered_chinese_doi_is_not_claimed():
|
||
"""Division of labour: a Crossref-registered Chinese DOI belongs to the
|
||
existing crossref resolver. It must stop without discarding the supplied
|
||
DOI or attempting the DOI-less coordinate branch."""
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_crossref.json"))),
|
||
(_is_doi_resolve, _http_error(500)), # must never be reached
|
||
(_is_handle, _http_error(500)), # must never be reached
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _client(journal_map=TEST_JOURNAL_MAP).resolve({
|
||
"citation_key": "chen2026",
|
||
"title": CN_TITLE,
|
||
"container_title": CN_JOURNAL,
|
||
"doi": "10.5555/crossref.zh.2026.1",
|
||
})
|
||
assert result["reason_code"] == "DOI_RA_OUT_OF_SCOPE"
|
||
assert result["status"] == "skipped"
|
||
assert result["checklist_item"]["priority"] == "P3"
|
||
assert len(transport.requests) == 1 # RA lookup only
|
||
|
||
|
||
def test_unknown_doi_ra_does_not_fall_through_to_coordinates():
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_unknown_prefix.json"))),
|
||
(_is_esearch, _http_error(500)), # must never be reached
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _client(journal_map=TEST_JOURNAL_MAP).resolve(
|
||
_istic_entry(doi="10.99999/ars.cn.unknown.2026.1")
|
||
)
|
||
assert result["status"] == "skipped"
|
||
assert result["reason_code"] == "DOI_RA_UNRESOLVED"
|
||
assert result["checklist_item"]["priority"] == "P2"
|
||
assert len(transport.requests) == 1
|
||
|
||
|
||
# ------------------------------------------------------------ ISTIC DOI branch
|
||
|
||
|
||
def _istic_entry(**overrides):
|
||
entry = {
|
||
"citation_key": "hecheng2026",
|
||
"title": CN_TITLE,
|
||
"container_title": CN_JOURNAL,
|
||
"year": 2026,
|
||
"volume": "88",
|
||
"pages": "8801-8809",
|
||
"doi": ISTIC_DOI,
|
||
}
|
||
entry.update(overrides)
|
||
return entry
|
||
|
||
|
||
def test_istic_doi_hit_with_matching_chinese_title_is_matched_by_id():
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_istic.json"))),
|
||
(_is_doi_resolve, _ok(_body("istic_csl_hit.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _client().resolve(_istic_entry())
|
||
|
||
assert result["status"] == "matched"
|
||
assert result["queried_by"] == "id"
|
||
assert result["reason_code"] == "DOI_TITLE_VERIFIED"
|
||
assert result["evidence"]["title"] == CN_TITLE
|
||
assert result["evidence"]["container_title"] == CN_JOURNAL
|
||
assert result["evidence"]["year"] == 2026
|
||
assert result["checklist_item"] is None
|
||
# Exactly two requests: no Handle probe is needed once the title verifies.
|
||
assert len(transport.requests) == 2
|
||
|
||
|
||
def test_istic_doi_hit_matches_a_typeset_variant_of_the_cited_title():
|
||
"""End-to-end companion to the unit regression: a citation whose title
|
||
differs from the record only by fullwidth forms, CJK punctuation and
|
||
interior spaces must MATCH, not land at P0. Typesetting differences between
|
||
a reference list and a metadata record are ordinary, not evidence."""
|
||
variant = "合成 引文核验闸 的确定性评估方法研究。"
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_istic.json"))),
|
||
(_is_doi_resolve, _ok(_body("istic_csl_hit.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _client().resolve(_istic_entry(title=variant))
|
||
|
||
assert result["status"] == "matched"
|
||
assert result["queried_by"] == "id"
|
||
assert result["checklist_item"] is None
|
||
|
||
|
||
def test_istic_doi_resolving_to_a_different_title_is_id_keyed_unmatched():
|
||
"""Chimeric citation (real identifier, someone else's title). ID-keyed
|
||
unmatched is the C-V6(a) shape that licenses a `false`, and it is a P0 row."""
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_istic.json"))),
|
||
(_is_doi_resolve, _ok(_body("istic_csl_other_title.json"))),
|
||
(_is_handle, _ok(_body("handle_exists.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _client().resolve(_istic_entry())
|
||
|
||
assert result["status"] == "unmatched"
|
||
assert result["queried_by"] == "id"
|
||
assert result["reason_code"] == "DOI_TITLE_MISMATCH"
|
||
item = result["checklist_item"]
|
||
assert item["priority"] == "P0"
|
||
assert item["verdict_contribution"] == "false"
|
||
assert item["human_result"] is None
|
||
|
||
|
||
def test_istic_english_shadow_title_is_unverifiable_never_false():
|
||
"""An English translation cannot refute the cited Chinese original.
|
||
|
||
PubMed and some DOI metadata surfaces expose English shadow titles for
|
||
Chinese articles. Without a translation oracle, a cross-script difference
|
||
is not a machine-verifiable chimeric citation.
|
||
"""
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_istic.json"))),
|
||
(_is_doi_resolve, _ok(
|
||
b'{"title":"Deterministic Evaluation of a Synthetic Citation Gate"}'
|
||
)),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _client().resolve(_istic_entry())
|
||
|
||
assert result["status"] == "unmatched"
|
||
assert result["queried_by"] == "title"
|
||
assert result["reason_code"] == "DOI_EXISTS_TITLE_UNVERIFIABLE"
|
||
assert result["evidence"]["resolved_title"].startswith("Deterministic")
|
||
assert result["checklist_item"]["verdict_contribution"] == "unresolvable"
|
||
assert len(transport.requests) == 2
|
||
|
||
|
||
def test_two_different_latin_titles_are_unverifiable_not_mismatch():
|
||
"""Matching script alone cannot make romanized titles comparable."""
|
||
from chinese_literature_client import DoiTitleState
|
||
|
||
transport = FakeTransport([
|
||
(_is_doi_resolve, _ok(b'{"title":"Romanized title alpha"}')),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
outcome = _client().doi_lookup_with_title_check(
|
||
ISTIC_DOI, "Romanized title beta",
|
||
)
|
||
|
||
assert outcome.state is DoiTitleState.UNVERIFIABLE
|
||
assert outcome.record["title"] == "Romanized title alpha"
|
||
|
||
|
||
def test_lone_surrogate_doi_is_typed_unavailable_after_ra_lookup():
|
||
"""A schema-valid JSON string still may not be UTF-8 encodable."""
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
entry = _istic_entry(doi="10.5555/" + chr(0xD800))
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_istic.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable, match="invalid Unicode"):
|
||
_client().resolve(entry)
|
||
|
||
assert len(transport.requests) == 1
|
||
|
||
|
||
def test_fabricated_doi_is_refuted_by_404_plus_handle_100():
|
||
"""The fabrication canary: these prefixes carry no wildcard catch-all, so a
|
||
404 + responseCode 100 is positive evidence the identifier does not exist."""
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_istic.json"))),
|
||
(_is_doi_resolve, _http_error(404)),
|
||
(_is_handle, _ok(_body("handle_absent.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _client().resolve(_istic_entry(doi=FABRICATED_DOI))
|
||
|
||
assert result["status"] == "unmatched"
|
||
assert result["queried_by"] == "id"
|
||
assert result["reason_code"] == "DOI_REFUTED"
|
||
item = result["checklist_item"]
|
||
assert item["priority"] == "P0"
|
||
assert item["verdict_contribution"] == "false"
|
||
|
||
|
||
def test_doi_404_is_a_negative_not_a_degradation():
|
||
"""A 404 on the DOI path must be reported as data. Turning it into an
|
||
outage would throw away the resolver's strongest signal."""
|
||
from chinese_literature_client import (
|
||
ChineseLiteratureUnavailable,
|
||
DoiTitleState,
|
||
)
|
||
|
||
transport = FakeTransport([(_is_doi_resolve, _http_error(404))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
client = _client()
|
||
try:
|
||
outcome = client.doi_lookup_with_title_check(FABRICATED_DOI, CN_TITLE)
|
||
assert outcome.state is DoiTitleState.NOT_FOUND
|
||
assert outcome.record is None
|
||
except ChineseLiteratureUnavailable: # pragma: no cover - failure path
|
||
pytest.fail("a DOI 404 must be a negative, not a degradation")
|
||
|
||
|
||
def test_doi_keyed_editorial_title_verifies_end_to_end():
|
||
from chinese_literature_client import DoiTitleState
|
||
|
||
transport = FakeTransport([
|
||
(_is_doi_resolve, _ok(b'{"title": "Editorial"}')),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
outcome = _client().doi_lookup_with_title_check(ISTIC_DOI, "Editorial")
|
||
assert outcome.state is DoiTitleState.MATCH
|
||
assert outcome.record["title"] == "Editorial"
|
||
|
||
|
||
def test_doi_404_plus_existing_handle_is_title_unverifiable_never_false():
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_istic.json"))),
|
||
(_is_doi_resolve, _http_error(404)),
|
||
(_is_handle, _ok(_body("handle_exists.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _client().resolve(_istic_entry())
|
||
|
||
assert result["status"] == "unmatched"
|
||
assert result["queried_by"] == "title"
|
||
assert result["reason_code"] == "DOI_EXISTS_TITLE_UNVERIFIABLE"
|
||
assert result["checklist_item"]["verdict_contribution"] == "unresolvable"
|
||
|
||
|
||
def test_empty_expected_title_is_unverifiable_never_mismatch_or_false():
|
||
from chinese_literature_client import DoiTitleState
|
||
|
||
direct = FakeTransport([
|
||
(_is_doi_resolve, _ok(_body("istic_csl_hit.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", direct):
|
||
outcome = _client().doi_lookup_with_title_check(ISTIC_DOI, "")
|
||
assert outcome.state is DoiTitleState.UNVERIFIABLE
|
||
assert outcome.record["title"] == CN_TITLE
|
||
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_istic.json"))),
|
||
(_is_doi_resolve, _ok(_body("istic_csl_hit.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _client().resolve(_istic_entry(title=""))
|
||
assert result["reason_code"] == "DOI_EXISTS_TITLE_UNVERIFIABLE"
|
||
assert result["queried_by"] == "title"
|
||
assert result["checklist_item"]["verdict_contribution"] == "unresolvable"
|
||
|
||
|
||
def test_normalized_empty_resolved_title_is_unverifiable_not_mismatch():
|
||
from chinese_literature_client import DoiTitleState
|
||
|
||
transport = FakeTransport([
|
||
(_is_doi_resolve, _ok('{"title": "。"}'.encode())),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
outcome = _client().doi_lookup_with_title_check(ISTIC_DOI, CN_TITLE)
|
||
assert outcome.state is DoiTitleState.UNVERIFIABLE
|
||
|
||
|
||
# ------------------------------------------------------------- CNKI DOI branch
|
||
|
||
|
||
def test_cnki_doi_that_exists_is_never_matched():
|
||
"""The CNKI RA serves no CSL-JSON and we refuse to parse its HTML, so
|
||
existence alone must degrade to title-keyed unmatched (-> unresolvable),
|
||
never `matched` — otherwise a real DOI with a fabricated title passes."""
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_cnki.json"))),
|
||
(_is_handle, _ok(_body("handle_exists.json"))),
|
||
(_is_doi_resolve, _http_error(500)), # must never be reached
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _client().resolve(_istic_entry(doi=CNKI_DOI))
|
||
|
||
assert result["status"] == "unmatched"
|
||
assert result["queried_by"] == "title" # NOT "id" -> reduces to unresolvable
|
||
assert result["reason_code"] == "DOI_EXISTS_TITLE_UNVERIFIABLE"
|
||
item = result["checklist_item"]
|
||
assert item["priority"] == "P2"
|
||
assert item["verdict_contribution"] == "unresolvable"
|
||
assert item["verification_urls"] == ["https://doi.org/" + CNKI_DOI]
|
||
# Content negotiation is never attempted for a CNKI prefix.
|
||
assert len(transport.requests) == 2
|
||
|
||
|
||
def test_cnki_doi_that_does_not_exist_is_still_refuted():
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_cnki.json"))),
|
||
(_is_handle, _ok(_body("handle_absent.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _client().resolve(_istic_entry(doi=CNKI_DOI))
|
||
|
||
assert result["queried_by"] == "id"
|
||
assert result["reason_code"] == "DOI_REFUTED"
|
||
|
||
|
||
def test_non_csl_200_body_is_typed_unavailable():
|
||
"""A 200 non-JSON body can be a proxy/CDN error page. It must fail closed,
|
||
not become routine human-check evidence."""
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_istic.json"))),
|
||
(_is_doi_resolve, _ok(_body("cnki_landing_page.html"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().resolve(_istic_entry())
|
||
|
||
|
||
def test_parseable_csl_with_malformed_author_shape_is_typed_unavailable():
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
malformed = ('{"title": "' + CN_TITLE + '", "author": 42}').encode()
|
||
transport = FakeTransport([(_is_doi_resolve, _ok(malformed))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().doi_lookup_with_title_check(ISTIC_DOI, CN_TITLE)
|
||
|
||
|
||
def test_parseable_csl_with_malformed_root_is_typed_unavailable():
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
transport = FakeTransport([(_is_doi_resolve, _ok(b'[{"title": "x"}]'))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().doi_lookup_with_title_check(ISTIC_DOI, CN_TITLE)
|
||
|
||
|
||
@pytest.mark.parametrize("endpoint", ["ra", "csl"])
|
||
def test_deeply_nested_json_is_typed_unavailable(endpoint):
|
||
"""A small but adversarial upstream body must not leak RecursionError."""
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
body = b"[" * 2000 + b"]" * 2000
|
||
predicate = _is_ra if endpoint == "ra" else _is_doi_resolve
|
||
transport = FakeTransport([(predicate, _ok(body))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
if endpoint == "ra":
|
||
_client().ra_for(ISTIC_DOI)
|
||
else:
|
||
_client().doi_lookup_with_title_check(ISTIC_DOI, CN_TITLE)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"body",
|
||
[
|
||
b'{"title": {"value": "x"}}',
|
||
b'{"title": [42]}',
|
||
b'{"title": []}',
|
||
b'{"title": "x", "author": [42]}',
|
||
b'{"title": "x", "author": false}',
|
||
b'{"title": "x", "author": [{"given": 42}]}',
|
||
b'{"title": "x", "issued": []}',
|
||
b'{"title": "x", "issued": {"date-parts": {}}}',
|
||
b'{"title": "x", "issued": {"date-parts": [[true]]}}',
|
||
b'{"title": "x", "issued": {"date-parts": [[-1]]}}',
|
||
'{"title": "x", "issued": {"date-parts": [["٢٠٢٦"]]}}'.encode(),
|
||
b'{"title": "x", "issued": {"date-parts": [["99999"]]}}',
|
||
b'{"title": "x", "volume": {"value": "1"}}',
|
||
b'{"title": "x", "DOI": ["10.5555/x"]}',
|
||
(b'{"title": "' + CN_TITLE.encode() + b'", "DOI": ""}'),
|
||
(b'{"title": "' + CN_TITLE.encode()
|
||
+ b'", "DOI": "10.5555/a.different.record"}'),
|
||
],
|
||
)
|
||
def test_parseable_csl_with_malformed_fields_is_typed_unavailable(body):
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
transport = FakeTransport([(_is_doi_resolve, _ok(body))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().doi_lookup_with_title_check(ISTIC_DOI, CN_TITLE)
|
||
|
||
|
||
def test_handle_unknown_response_code_degrades():
|
||
"""responseCode 2 (internal error) is an unknown state — degrade, do not
|
||
guess an existence answer."""
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
transport = FakeTransport([(_is_handle, _ok(_body("handle_internal_error.json")))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().handle_exists(ISTIC_DOI)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"body",
|
||
[b'{"responseCode": true}', b'{"responseCode": 100.0}'],
|
||
)
|
||
def test_handle_bool_or_float_response_code_degrades(body):
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
transport = FakeTransport([(_is_handle, _ok(body))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().handle_exists(ISTIC_DOI)
|
||
|
||
|
||
# --------------------------------------------------- PubMed coordinate branch
|
||
|
||
|
||
def _coordinate_entry(**overrides):
|
||
entry = {
|
||
"citation_key": "hecheng2026",
|
||
"title": CN_TITLE,
|
||
"container_title": CN_JOURNAL,
|
||
"year": 2026,
|
||
"volume": "88",
|
||
"pages": "8801-8809",
|
||
}
|
||
entry.update(overrides)
|
||
return entry
|
||
|
||
|
||
def _coordinate_client():
|
||
return _client(journal_map=TEST_JOURNAL_MAP)
|
||
|
||
|
||
def test_missing_coordinate_tuple_is_p3_without_a_pubmed_request():
|
||
with patch(
|
||
"chinese_literature_client._safe_urlopen",
|
||
side_effect=AssertionError("no PubMed request"),
|
||
):
|
||
result = _coordinate_client().resolve(
|
||
_coordinate_entry(volume=None, pages=None, year=None)
|
||
)
|
||
|
||
assert result["status"] == "skipped"
|
||
assert result["queried_by"] is None
|
||
assert result["reason_code"] == "INSUFFICIENT_PUBMED_COORDINATES"
|
||
assert result["checklist_item"]["priority"] == "P3"
|
||
assert result["checklist_item"]["verdict_contribution"] == "unresolvable"
|
||
|
||
|
||
def test_lone_surrogate_eutils_parameter_is_typed_before_transport():
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
with patch(
|
||
"chinese_literature_client._safe_urlopen",
|
||
side_effect=AssertionError("no request for unencodable EUtils term"),
|
||
):
|
||
with pytest.raises(ChineseLiteratureUnavailable, match="invalid Unicode"):
|
||
_coordinate_client().journal_is_indexed("Journal " + chr(0xD800))
|
||
|
||
|
||
def test_volume_page_zero_hit_never_falls_back_to_author_year():
|
||
"""A valid author/year candidate must not wash a cited wrong page into a
|
||
match after the stronger volume/page query returned zero."""
|
||
esearch_bodies = [
|
||
_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coordinate_zero.json"),
|
||
]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(
|
||
_coordinate_entry(pages="9999", first_author_pinyin="Hecheng Ceshi")
|
||
)
|
||
|
||
assert result["reason_code"] == "PUBMED_INDEXED_BUT_COORDINATE_MISS"
|
||
assert len(transport.requests) == 2 # coverage + volume/page, no fallback
|
||
assert result["checklist_item"]["attempts"][-1]["detail"] == "volume_page"
|
||
|
||
|
||
def test_pubmed_candidate_page_conflict_cannot_be_promoted():
|
||
esearch_bodies = [
|
||
_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coordinate_hit.json"),
|
||
]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
(_is_esummary, _ok(_body("esummary_hit.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(_coordinate_entry(pages="9999"))
|
||
|
||
assert result["reason_code"] == "PUBMED_COORDINATE_CANDIDATE_UNVERIFIED"
|
||
assert result["status"] == "unmatched"
|
||
assert "first page" in result["checklist_item"]["human_action"]
|
||
assert len(transport.requests) == 3 # no DOI/RA lookup after conflict
|
||
|
||
|
||
def test_pubmed_candidate_volume_conflict_cannot_be_promoted():
|
||
"""A unique hit with a different volume stops before DOI/title binding."""
|
||
esearch_bodies = [
|
||
_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coordinate_hit.json"),
|
||
]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
(_is_esummary, _ok(_body("esummary_hit.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(_coordinate_entry(volume="99"))
|
||
|
||
assert result["reason_code"] == "PUBMED_COORDINATE_CANDIDATE_UNVERIFIED"
|
||
assert result["status"] == "unmatched"
|
||
assert "volume" in result["checklist_item"]["human_action"]
|
||
assert len(transport.requests) == 3 # no DOI/RA lookup after conflict
|
||
|
||
|
||
def test_pubmed_coordinate_candidate_is_matched_only_after_istic_title_binding():
|
||
"""Coordinates nominate a candidate; its ISTIC DOI and Chinese CSL title
|
||
provide the evidence that upgrades it to a title-keyed match."""
|
||
esearch_bodies = [_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coordinate_hit.json")]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
(_is_esummary, _ok(_body("esummary_hit.json"))),
|
||
(_is_ra, _ok(_body("ra_istic.json"))),
|
||
(_is_doi_resolve, _ok(_body("istic_csl_hit.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(_coordinate_entry())
|
||
|
||
assert result["status"] == "matched"
|
||
assert result["queried_by"] == "title"
|
||
assert result["reason_code"] == "PUBMED_COORDINATE_VERIFIED"
|
||
assert result["evidence"]["pmid"] == "999999901"
|
||
assert result["evidence"]["doi"] == ISTIC_DOI
|
||
assert result["evidence"]["doi_metadata"]["title"] == CN_TITLE
|
||
# PubMed stores the ENGLISH bracketed shadow title for Chinese articles, so
|
||
# a Chinese title cross-check is structurally impossible on this branch —
|
||
# the shadow title is carried for the human instead.
|
||
assert result["evidence"]["english_title"].startswith("[")
|
||
assert result["checklist_item"] is None
|
||
|
||
|
||
def test_pubmed_coordinate_miss_is_p1_never_false():
|
||
"""The single most important self-restraint in the design: coverage is
|
||
confirmed and the coordinates return nothing, yet the outcome stays
|
||
title-keyed (-> unresolvable). The strength goes into the P1 priority, not
|
||
into the verdict — the journal bridge is heuristic and PubMed indexes
|
||
Chinese journals selectively."""
|
||
esearch_bodies = [_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coordinate_zero.json")]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(_coordinate_entry(pages="9999"))
|
||
|
||
assert result["status"] == "unmatched"
|
||
assert result["queried_by"] == "title"
|
||
assert result["reason_code"] == "PUBMED_INDEXED_BUT_COORDINATE_MISS"
|
||
item = result["checklist_item"]
|
||
assert item["priority"] == "P1"
|
||
assert item["verdict_contribution"] == "unresolvable"
|
||
# Wording discipline: fabrication vocabulary is P0-only.
|
||
assert "pending human check" in item["human_action"].lower()
|
||
assert "fabricat" not in item["human_action"].lower()
|
||
|
||
|
||
def test_pubmed_coordinate_ambiguity_never_picks_one():
|
||
esearch_bodies = [_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coordinate_ambiguous.json")]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(_coordinate_entry())
|
||
|
||
assert result["reason_code"] == "PUBMED_COORDINATE_AMBIGUOUS"
|
||
assert result["queried_by"] == "title"
|
||
assert result["evidence"] is None
|
||
assert result["checklist_item"]["priority"] == "P1"
|
||
|
||
|
||
def test_pubmed_year_disagreement_demotes_the_hit():
|
||
esearch_bodies = [_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coordinate_hit.json")]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
(_is_esummary, _ok(_body("esummary_year_mismatch.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(_coordinate_entry())
|
||
|
||
assert result["status"] == "unmatched"
|
||
assert result["queried_by"] == "title"
|
||
assert result["reason_code"] == "PUBMED_COORDINATE_CANDIDATE_UNVERIFIED"
|
||
|
||
|
||
def test_pubmed_issn_disagreement_demotes_the_hit():
|
||
"""The coordinate hit's corroboration is structural, so the ISSN the record
|
||
echoes must be the ISSN we bridged through — a disagreement means the
|
||
journal-name mapping, not the citation, is likely at fault."""
|
||
esearch_bodies = [_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coordinate_hit.json")]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
(_is_esummary, _ok(_body("esummary_issn_mismatch.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(_coordinate_entry())
|
||
|
||
assert result["status"] == "unmatched"
|
||
assert result["queried_by"] == "title"
|
||
assert result["reason_code"] == "PUBMED_COORDINATE_CANDIDATE_UNVERIFIED"
|
||
assert "ISSN" in result["checklist_item"]["human_action"]
|
||
|
||
|
||
def test_absent_year_does_not_block_doi_title_binding():
|
||
"""Absence is not mismatch: exact Chinese title binding still verifies."""
|
||
esearch_bodies = [_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coordinate_hit.json")]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
(_is_esummary, _ok(_body("esummary_hit.json"))),
|
||
(_is_ra, _ok(_body("ra_istic.json"))),
|
||
(_is_doi_resolve, _ok(_body("istic_csl_hit.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(_coordinate_entry(year=None))
|
||
|
||
assert result["status"] == "matched"
|
||
|
||
|
||
def test_pubmed_coordinate_candidate_without_doi_is_never_matched():
|
||
esearch_bodies = [_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coordinate_hit.json")]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
(_is_esummary, _ok(_body("esummary_no_doi.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(_coordinate_entry())
|
||
|
||
assert result["status"] == "unmatched"
|
||
assert result["queried_by"] == "title"
|
||
assert result["reason_code"] == "PUBMED_COORDINATE_CANDIDATE_UNVERIFIED"
|
||
assert result["checklist_item"]["verdict_contribution"] == "unresolvable"
|
||
|
||
|
||
def test_pubmed_candidate_doi_title_mismatch_never_washes_fake_chinese_title():
|
||
esearch_bodies = [_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coordinate_hit.json")]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
(_is_esummary, _ok(_body("esummary_hit.json"))),
|
||
(_is_ra, _ok(_body("ra_istic.json"))),
|
||
(_is_doi_resolve, _ok(_body("istic_csl_other_title.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(_coordinate_entry())
|
||
|
||
assert result["status"] == "unmatched"
|
||
assert result["queried_by"] == "title"
|
||
assert result["reason_code"] == "PUBMED_COORDINATE_CANDIDATE_UNVERIFIED"
|
||
assert result["checklist_item"]["verdict_contribution"] == "unresolvable"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"esummary_fixture,ra_fixture",
|
||
[
|
||
("esummary_hit.json", "ra_crossref.json"),
|
||
("esummary_unknown_ra.json", "ra_unknown_prefix.json"),
|
||
],
|
||
)
|
||
def test_pubmed_candidate_out_of_scope_or_unknown_ra_is_never_matched(
|
||
esummary_fixture, ra_fixture,
|
||
):
|
||
esearch_bodies = [_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coordinate_hit.json")]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
(_is_esummary, _ok(_body(esummary_fixture))),
|
||
(_is_ra, _ok(_body(ra_fixture))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(_coordinate_entry())
|
||
|
||
assert result["status"] == "unmatched"
|
||
assert result["queried_by"] == "title"
|
||
assert result["reason_code"] == "PUBMED_COORDINATE_CANDIDATE_UNVERIFIED"
|
||
|
||
|
||
def test_pubmed_candidate_cnki_handle_existence_is_never_matched():
|
||
esearch_bodies = [_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coordinate_hit.json")]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
(_is_esummary, _ok(_body("esummary_hit.json"))),
|
||
(_is_ra, _ok(_body("ra_cnki.json"))),
|
||
(_is_handle, _ok(_body("handle_exists.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(_coordinate_entry())
|
||
|
||
assert result["status"] == "unmatched"
|
||
assert result["reason_code"] == "PUBMED_COORDINATE_CANDIDATE_UNVERIFIED"
|
||
assert any(
|
||
attempt["stage"] == "pubmed_candidate_handle"
|
||
and attempt["outcome"] == "exists"
|
||
for attempt in result["checklist_item"]["attempts"]
|
||
)
|
||
|
||
|
||
def test_journal_catalogued_but_not_indexed_is_skipped_not_unmatched():
|
||
"""'In the NLM Catalog' != 'indexed in PubMed' (中国全科医学 is catalogued
|
||
with zero PubMed articles). A journal PubMed never indexed must produce
|
||
`skipped` + a P3 row, because a coordinate miss against it says nothing."""
|
||
transport = FakeTransport([
|
||
(_is_esearch, _ok(_body("esearch_coverage_zero.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(_coordinate_entry())
|
||
|
||
assert result["status"] == "skipped"
|
||
assert result["queried_by"] is None
|
||
assert result["reason_code"] == "JOURNAL_NOT_INDEXED"
|
||
assert result["checklist_item"]["priority"] == "P3"
|
||
# Coverage confirmation only — the coordinate query is never issued.
|
||
assert len(transport.requests) == 1
|
||
|
||
|
||
def test_unmapped_journal_is_skipped_with_a_p3_row():
|
||
"""A gap in OUR bridge table is never evidence about the citation."""
|
||
transport = FakeTransport([])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _coordinate_client().resolve(
|
||
_coordinate_entry(container_title="某本尚未映射的中文期刊"))
|
||
|
||
assert result["status"] == "skipped"
|
||
assert result["reason_code"] == "NO_ISSN_MAPPING"
|
||
assert result["checklist_item"]["priority"] == "P3"
|
||
assert transport.requests == []
|
||
|
||
|
||
def test_seed_journal_map_rows_are_lookup_reachable():
|
||
"""The shipped seed rows (all verified live 2026-07-27) must be reachable
|
||
through the same normalization the resolver applies, including 《》 wrapping."""
|
||
client = _client()
|
||
row = client.journal_bridge("中华医学杂志")
|
||
assert row is not None and row["nlm_ta"] == "Zhonghua Yi Xue Za Zhi"
|
||
assert client.journal_bridge("《中华医学杂志》") == row
|
||
assert client.journal_bridge("中华内科杂志")["issn"] == "0578-1426"
|
||
|
||
|
||
def test_caller_journal_map_extends_the_seed():
|
||
"""Documented user extension point: caller rows merge over the seed."""
|
||
client = _client(journal_map=TEST_JOURNAL_MAP)
|
||
assert client.journal_bridge(CN_JOURNAL)["nlm_ta"] == "Hecheng Ceshi Yi Xue Za Zhi"
|
||
assert client.journal_bridge("中华医学杂志") is not None # seed still present
|
||
|
||
|
||
def test_ncbi_api_key_is_passed_through_but_does_not_relax_pacing():
|
||
from chinese_literature_client import _EUTILS_MIN_INTERVAL
|
||
|
||
transport = FakeTransport([(_is_esearch, _ok(_body("esearch_coverage_hit.json")))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
client = _client(ncbi_api_key="test-ncbi-key")
|
||
client.journal_is_indexed("Hecheng Ceshi Yi Xue Za Zhi")
|
||
|
||
assert "api_key=test-ncbi-key" in transport.requests[0]
|
||
assert "tool=academic-research-skills" in transport.requests[0]
|
||
assert "email=ars-tests%40example.invalid" in transport.requests[0]
|
||
assert _EUTILS_MIN_INTERVAL == 0.34 # the keyless NCBI floor, unconditionally
|
||
|
||
|
||
def test_ncbi_email_is_required_before_any_eutils_request():
|
||
from chinese_literature_client import (
|
||
ChineseLiteratureClient,
|
||
ChineseLiteratureUnavailable,
|
||
)
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", side_effect=AssertionError("no request")):
|
||
with pytest.raises(ChineseLiteratureUnavailable, match="ncbi_email"):
|
||
ChineseLiteratureClient().journal_is_indexed("Hecheng Ceshi Yi Xue Za Zhi")
|
||
|
||
|
||
def test_pubmed_author_fallback_uses_first_author_tag():
|
||
bodies = [_body("esearch_coordinate_zero.json")]
|
||
transport = FakeTransport([
|
||
(_is_esearch, lambda url: _ok(bodies.pop(0))(url)),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
_client().pubmed_coordinate_lookup(
|
||
nlm_ta="Hecheng Ceshi Yi Xue Za Zhi",
|
||
first_author="Hecheng Ceshi",
|
||
year=2026,
|
||
)
|
||
term = urllib.parse.parse_qs(
|
||
urllib.parse.urlsplit(transport.requests[0]).query
|
||
)["term"][0]
|
||
assert '"Hecheng Ceshi"[1au]' in term
|
||
assert "[au]" not in term
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"kwargs",
|
||
[
|
||
{"volume": "88[vi] OR 1", "pages": "8801"},
|
||
{"volume": "88", "pages": "8801[pg] OR 1"},
|
||
{"volume": "1:9999", "pages": "8801"},
|
||
{"first_author": 'Chen" OR cancer[ti]', "year": 2026},
|
||
],
|
||
)
|
||
def test_untrusted_pubmed_fields_cannot_inject_entrez_grammar(kwargs):
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", side_effect=AssertionError("no request")):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().pubmed_coordinate_lookup(
|
||
nlm_ta="Hecheng Ceshi Yi Xue Za Zhi", **kwargs
|
||
)
|
||
|
||
|
||
# -------------------------------------------------- URL hygiene / credentials
|
||
|
||
|
||
def test_malicious_doi_is_encoded_and_never_crashes():
|
||
"""A DOI carrying CRLF / query-injection characters must be percent-encoded
|
||
before it reaches the transport (sibling quote discipline) — no raw control
|
||
character or `?`/`#` may survive into the request line, and the client must
|
||
never escape its own degradation contract over a hostile identifier."""
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
evil = "10.5555/evil\r\nHost: attacker?x=1#frag"
|
||
transport = FakeTransport([
|
||
(_is_handle, _ok(_body("handle_absent.json"))),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
assert _client().handle_exists(evil) is False
|
||
|
||
url = transport.requests[0]
|
||
assert "\r" not in url and "\n" not in url
|
||
assert "?" not in url and "#" not in url # no query/fragment injection
|
||
assert "%0D%0A" in url # CRLF arrived encoded, not stripped
|
||
|
||
# Defense in depth: even if a malformed request slips past encoding and
|
||
# urlopen rejects it (http.client.InvalidURL), the client degrades cleanly.
|
||
def reject(req, timeout=None):
|
||
raise http.client.InvalidURL("URL can't contain control characters")
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", reject):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().handle_exists(evil)
|
||
|
||
|
||
def test_api_key_never_lands_in_exception_text():
|
||
"""#495 red line (mirrors crossref/openalex): refusal/degradation messages
|
||
strip the query string so a configured NCBI api_key can never reach logs or
|
||
raised-exception text — checked across the 5xx, network-error and
|
||
unparseable-body paths."""
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
key = "test-ncbi-key"
|
||
|
||
scenarios = [
|
||
FakeTransport([(_is_esearch, _http_error(503))]),
|
||
FakeTransport([(_is_esearch, _ok(_body("error_5xx.html")))]),
|
||
]
|
||
for transport in scenarios:
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable) as excinfo:
|
||
_client(ncbi_api_key=key).journal_is_indexed("Hecheng Ceshi")
|
||
assert key not in str(excinfo.value)
|
||
assert "api_key" not in str(excinfo.value)
|
||
assert "?" not in str(excinfo.value) # whole query stripped, not just the key
|
||
|
||
def net_error(req, timeout=None):
|
||
raise urllib.error.URLError("connection refused")
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", net_error):
|
||
with pytest.raises(ChineseLiteratureUnavailable) as excinfo:
|
||
_client(ncbi_api_key=key).journal_is_indexed("Hecheng Ceshi")
|
||
assert key not in str(excinfo.value)
|
||
assert "?" not in str(excinfo.value)
|
||
|
||
|
||
@pytest.mark.parametrize("failure_kind", ["http", "url"])
|
||
def test_reflected_request_url_is_absent_from_full_exception_traceback(failure_kind):
|
||
"""Removing a secret from the wrapper message is insufficient when the
|
||
original exception remains as an explicit cause: logging the traceback
|
||
would print that cause and its full query string."""
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
key = "SECRET123"
|
||
|
||
def reflected(req, timeout=None):
|
||
if failure_kind == "http":
|
||
raise urllib.error.HTTPError(
|
||
url=req.full_url,
|
||
code=503,
|
||
msg=f"upstream reflected {req.full_url}",
|
||
hdrs={},
|
||
fp=io.BytesIO(),
|
||
)
|
||
raise urllib.error.URLError(f"upstream reflected {req.full_url}")
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", reflected):
|
||
with pytest.raises(ChineseLiteratureUnavailable) as excinfo:
|
||
_client(ncbi_api_key=key).journal_is_indexed("Hecheng Ceshi")
|
||
|
||
rendered = "".join(traceback.format_exception(
|
||
excinfo.type, excinfo.value, excinfo.tb,
|
||
))
|
||
assert key not in rendered
|
||
assert f"api_key={key}" not in rendered
|
||
|
||
|
||
@pytest.mark.parametrize("error_url", ["", "http://evil.invalid/stolen"])
|
||
def test_rejected_http_error_url_does_not_retain_reflected_secret(error_url):
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
key = "SECRET123"
|
||
|
||
def reflected(req, timeout=None):
|
||
raise urllib.error.HTTPError(
|
||
url=error_url,
|
||
code=503,
|
||
msg=f"upstream reflected {req.full_url}",
|
||
hdrs={},
|
||
fp=io.BytesIO(),
|
||
)
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", reflected):
|
||
with pytest.raises(ChineseLiteratureUnavailable) as excinfo:
|
||
_client(ncbi_api_key=key).journal_is_indexed("Hecheng Ceshi")
|
||
|
||
rendered = "".join(traceback.format_exception(
|
||
excinfo.type, excinfo.value, excinfo.tb,
|
||
))
|
||
assert key not in rendered
|
||
|
||
|
||
def test_malformed_port_does_not_retain_secret_as_a_cause():
|
||
from chinese_literature_client import ChineseLiteratureUnavailable, _require_api_url
|
||
|
||
key = "SECRET123"
|
||
with pytest.raises(ChineseLiteratureUnavailable) as excinfo:
|
||
_require_api_url(f"https://doi.org:{key}/path?api_key={key}")
|
||
|
||
rendered = "".join(traceback.format_exception(
|
||
excinfo.type, excinfo.value, excinfo.tb,
|
||
))
|
||
assert key not in rendered
|
||
|
||
|
||
def test_host_allowlist_guard_refuses_and_redacts():
|
||
"""Multi-host `_require_api_url` (sibling defense-in-depth): a non-HTTPS or
|
||
non-allowlisted host is refused before any request, and the refusal text is
|
||
query-stripped."""
|
||
from chinese_literature_client import (
|
||
ChineseLiteratureUnavailable,
|
||
_require_api_url,
|
||
)
|
||
|
||
_require_api_url("https://doi.org/doiRA/10.5555") # allowlisted: no raise
|
||
_require_api_url("https://eutils.ncbi.nlm.nih.gov/x") # allowlisted: no raise
|
||
for bad in (
|
||
"https://www.cnki.net/search?q=x", # zero-scraping red line
|
||
"http://doi.org/doiRA/10.5555", # https only
|
||
"https://evil.invalid/a?api_key=test-ncbi-key",
|
||
"https://127.0.0.1/doiRA/10.5555", # no bare IPs
|
||
"https://user@doi.org/doiRA/10.5555", # no userinfo
|
||
"https://doi.org:443/doiRA/10.5555", # exact origin, no ports
|
||
):
|
||
with pytest.raises(ChineseLiteratureUnavailable) as excinfo:
|
||
_require_api_url(bad)
|
||
assert "api_key" not in str(excinfo.value)
|
||
assert "?" not in str(excinfo.value)
|
||
|
||
|
||
def test_redirect_handler_revalidates_every_destination_before_following():
|
||
from chinese_literature_client import (
|
||
ChineseLiteratureUnavailable,
|
||
_SafeRedirectHandler,
|
||
)
|
||
|
||
handler = _SafeRedirectHandler()
|
||
request = urllib.request.Request("https://doi.org/10.5555/example")
|
||
for target in (
|
||
"http://doi.org/10.5555/example", # downgrade
|
||
"https://127.0.0.1/10.5555/example", # bare IP
|
||
"https://user@doi.org/10.5555/example", # userinfo
|
||
"https://doi.org:443/10.5555/example", # explicit port
|
||
"https://doi.org.evil.invalid/example", # suffix confusion
|
||
"https://evil.invalid/example", # external host
|
||
):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
handler.redirect_request(request, None, 302, "Found", {}, target)
|
||
|
||
redirected = handler.redirect_request(
|
||
request, None, 302, "Found", {},
|
||
"https://hdl.handle.net/api/handles/10.5555/example",
|
||
)
|
||
assert redirected.full_url.startswith("https://hdl.handle.net/")
|
||
|
||
|
||
@pytest.mark.parametrize("code", [301, 302, 303, 307, 308])
|
||
def test_redirect_response_body_is_never_read_before_following(code):
|
||
from chinese_literature_client import _SafeRedirectHandler
|
||
|
||
handler = _SafeRedirectHandler()
|
||
handler.parent = MagicMock()
|
||
request = urllib.request.Request("https://doi.org/10.5555/example")
|
||
request.timeout = 30
|
||
response = MagicMock()
|
||
redirected_response = object()
|
||
handler.parent.open.return_value = redirected_response
|
||
|
||
result = getattr(handler, f"http_error_{code}")(
|
||
request,
|
||
response,
|
||
code,
|
||
"Found",
|
||
{"location": "/10.5555/next"},
|
||
)
|
||
|
||
assert result is redirected_response
|
||
response.read.assert_not_called()
|
||
response.close.assert_called_once_with()
|
||
followed = handler.parent.open.call_args.args[0]
|
||
assert followed.full_url == "https://doi.org/10.5555/next"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"location",
|
||
["http://doi.org/downgrade", "https://evil.invalid/escape", 42],
|
||
)
|
||
def test_rejected_redirect_is_closed_without_reading(location):
|
||
from chinese_literature_client import (
|
||
ChineseLiteratureUnavailable,
|
||
_SafeRedirectHandler,
|
||
)
|
||
|
||
handler = _SafeRedirectHandler()
|
||
request = urllib.request.Request("https://doi.org/10.5555/example")
|
||
request.timeout = 30
|
||
response = MagicMock()
|
||
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
handler.http_error_302(
|
||
request, response, 302, "Found", {"location": location},
|
||
)
|
||
|
||
response.read.assert_not_called()
|
||
response.close.assert_called_once_with()
|
||
|
||
|
||
def test_redirect_without_location_is_closed_without_reading():
|
||
from chinese_literature_client import (
|
||
ChineseLiteratureUnavailable,
|
||
_SafeRedirectHandler,
|
||
)
|
||
|
||
handler = _SafeRedirectHandler()
|
||
request = urllib.request.Request("https://doi.org/10.5555/example")
|
||
request.timeout = 30
|
||
response = MagicMock()
|
||
|
||
with pytest.raises(ChineseLiteratureUnavailable, match="missing a Location"):
|
||
handler.http_error_302(request, response, 302, "Found", {})
|
||
|
||
response.read.assert_not_called()
|
||
response.close.assert_called_once_with()
|
||
|
||
|
||
def test_istic_plaintext_redirect_stops_resolve_before_any_verdict():
|
||
"""End-to-end regression for the maintainer's live ISTIC observation.
|
||
|
||
RA triage may identify ISTIC, but the subsequent DOI request currently has
|
||
the shape ``HTTPS doi.org -> HTTP bare IP``. Exercise the real resolver and
|
||
redirect handler together: the unsafe hop must be closed unread, propagate
|
||
as typed degradation, and never return a MATCH/MISMATCH-shaped result.
|
||
"""
|
||
from chinese_literature_client import (
|
||
ChineseLiteratureUnavailable,
|
||
_SafeRedirectHandler,
|
||
)
|
||
|
||
requests: list[str] = []
|
||
redirect_response = MagicMock()
|
||
|
||
def transport(req, timeout=None):
|
||
url = req.full_url
|
||
requests.append(url)
|
||
parsed = urllib.parse.urlsplit(url)
|
||
query = (
|
||
urllib.parse.parse_qs(
|
||
parsed.query, keep_blank_values=True, strict_parsing=True,
|
||
)
|
||
if parsed.query else {}
|
||
)
|
||
if _is_ra(parsed, query):
|
||
return _ok(_body("ra_istic.json"))(url)
|
||
if _is_doi_resolve(parsed, query):
|
||
handler = _SafeRedirectHandler()
|
||
return handler.http_error_302(
|
||
req,
|
||
redirect_response,
|
||
302,
|
||
"Found",
|
||
{"location": "http://122.115.55.36:8000/doi/synthetic"},
|
||
)
|
||
raise AssertionError(f"client contacted an unrouted URL: {url}")
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().resolve(_istic_entry())
|
||
|
||
assert len(requests) == 2 # RA triage, then the refused DOI request
|
||
redirect_response.read.assert_not_called()
|
||
redirect_response.close.assert_called_once_with()
|
||
|
||
|
||
def test_final_response_url_is_revalidated_even_after_transport_returns():
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
response = MagicMock()
|
||
response.status = 200
|
||
response.headers = {}
|
||
response.geturl.return_value = "http://127.0.0.1/stolen"
|
||
response.read.return_value = _body("ra_istic.json")
|
||
response.__enter__ = MagicMock(return_value=response)
|
||
response.__exit__ = MagicMock(return_value=None)
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", return_value=response):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().ra_for(ISTIC_DOI)
|
||
response.read.assert_not_called()
|
||
|
||
|
||
def test_http_error_final_url_is_revalidated_before_404_becomes_data():
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
def escaped_404(req, timeout=None):
|
||
raise urllib.error.HTTPError(
|
||
url="http://127.0.0.1/not-found",
|
||
code=404,
|
||
msg="synthetic",
|
||
hdrs={},
|
||
fp=io.BytesIO(b""),
|
||
)
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", escaped_404):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().doi_lookup_with_title_check(FABRICATED_DOI, CN_TITLE)
|
||
|
||
|
||
@pytest.mark.parametrize("code", [404, 503])
|
||
def test_http_error_response_is_closed_without_reading(code):
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
error_body = MagicMock()
|
||
|
||
def fail(req, timeout=None):
|
||
raise urllib.error.HTTPError(
|
||
url=req.full_url,
|
||
code=code,
|
||
msg="synthetic",
|
||
hdrs={},
|
||
fp=error_body,
|
||
)
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", fail):
|
||
if code == 404:
|
||
assert _client().handle_exists(FABRICATED_DOI) is False
|
||
else:
|
||
with pytest.raises(ChineseLiteratureUnavailable, match="HTTP 503"):
|
||
_client().handle_exists(FABRICATED_DOI)
|
||
|
||
error_body.read.assert_not_called()
|
||
error_body.close.assert_called_once_with()
|
||
|
||
|
||
@pytest.mark.parametrize("status", [None, 0, 503, "200"])
|
||
def test_returned_invalid_or_non_success_status_is_rejected_before_read(status):
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
response = MagicMock()
|
||
response.status = status
|
||
response.headers = {}
|
||
response.geturl.return_value = "https://doi.org/doiRA/10.5555"
|
||
response.__enter__ = MagicMock(return_value=response)
|
||
response.__exit__ = MagicMock(return_value=None)
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", return_value=response):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().ra_for(ISTIC_DOI)
|
||
response.read.assert_not_called()
|
||
|
||
|
||
def test_returned_404_is_data_only_on_an_allow_404_path():
|
||
response = MagicMock()
|
||
response.status = 404
|
||
response.headers = {}
|
||
response.geturl.return_value = "https://doi.org/doiRA/10.5555"
|
||
response.__enter__ = MagicMock(return_value=response)
|
||
response.__exit__ = MagicMock(return_value=None)
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", return_value=response):
|
||
assert _client().ra_for(ISTIC_DOI) is None
|
||
response.read.assert_not_called()
|
||
|
||
|
||
def test_content_length_precheck_rejects_oversized_body_without_reading():
|
||
from chinese_literature_client import ChineseLiteratureUnavailable, MAX_BODY_BYTES
|
||
|
||
response = MagicMock()
|
||
response.status = 200
|
||
response.headers = {"Content-Length": str(MAX_BODY_BYTES + 1)}
|
||
response.geturl.return_value = "https://doi.org/doiRA/10.5555"
|
||
response.__enter__ = MagicMock(return_value=response)
|
||
response.__exit__ = MagicMock(return_value=None)
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", return_value=response):
|
||
with pytest.raises(ChineseLiteratureUnavailable, match="exceeds"):
|
||
_client().ra_for(ISTIC_DOI)
|
||
response.read.assert_not_called()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"value",
|
||
["+1", "-0", "1_0", "١", " 1 x ", "\u00a01\u00a0", "9" * 5000],
|
||
)
|
||
def test_malformed_content_length_is_rejected_before_read(value):
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
response = MagicMock()
|
||
response.status = 200
|
||
response.headers = {"Content-Length": value}
|
||
response.geturl.return_value = "https://doi.org/doiRA/10.5555"
|
||
response.__enter__ = MagicMock(return_value=response)
|
||
response.__exit__ = MagicMock(return_value=None)
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", return_value=response):
|
||
with pytest.raises(ChineseLiteratureUnavailable, match="Content-Length"):
|
||
_client().ra_for(ISTIC_DOI)
|
||
response.read.assert_not_called()
|
||
|
||
|
||
def test_body_read_is_bounded_and_rejects_stream_over_limit():
|
||
from chinese_literature_client import ChineseLiteratureUnavailable, MAX_BODY_BYTES
|
||
|
||
response = MagicMock()
|
||
response.status = 200
|
||
response.headers = {}
|
||
response.geturl.return_value = "https://doi.org/doiRA/10.5555"
|
||
response.read.return_value = b"x" * (MAX_BODY_BYTES + 1)
|
||
response.__enter__ = MagicMock(return_value=response)
|
||
response.__exit__ = MagicMock(return_value=None)
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", return_value=response):
|
||
with pytest.raises(ChineseLiteratureUnavailable, match="exceeds"):
|
||
_client().ra_for(ISTIC_DOI)
|
||
response.read.assert_called_once_with(MAX_BODY_BYTES + 1)
|
||
|
||
|
||
# ------------------------------------------------------- degradation contract
|
||
|
||
|
||
def test_5xx_does_not_retry():
|
||
"""Per protocol: 5xx -> fail fast, exactly one request."""
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
transport = FakeTransport([(_is_ra, _http_error(503, _body("error_5xx.html")))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().ra_for(ISTIC_DOI)
|
||
|
||
assert len(transport.requests) == 1
|
||
|
||
|
||
def test_429_backs_off_then_raises_after_the_shared_retry_budget(monkeypatch):
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
from _text_similarity import _MAX_RETRIES
|
||
|
||
sleeps: list[float] = []
|
||
monkeypatch.setattr("chinese_literature_client.time.sleep", sleeps.append)
|
||
|
||
transport = FakeTransport([(_is_ra, _http_error(429))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().ra_for(ISTIC_DOI)
|
||
|
||
assert len(transport.requests) == _MAX_RETRIES + 1
|
||
# Growing backoff, floored at 2s so a retry cannot re-violate the limit the
|
||
# 429 is enforcing (arxiv_client.py's rule).
|
||
assert sleeps == [2.0, 4.0, 6.0]
|
||
assert all(delay >= 2.0 for delay in sleeps)
|
||
|
||
|
||
def test_network_error_raises_unavailable():
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
def boom(req, timeout=None):
|
||
raise urllib.error.URLError("connection refused")
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", boom):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().ra_for(ISTIC_DOI)
|
||
|
||
|
||
@pytest.mark.parametrize("failure_point", ["open", "close"])
|
||
def test_raw_oserror_from_transport_or_response_close_is_typed(failure_point):
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
if failure_point == "open":
|
||
transport = MagicMock(side_effect=OSError("socket failure"))
|
||
else:
|
||
response = MagicMock()
|
||
response.status = 200
|
||
response.headers = {}
|
||
response.geturl.return_value = "https://doi.org/doiRA/10.5555"
|
||
response.read.return_value = _body("ra_istic.json")
|
||
response.__enter__ = MagicMock(return_value=response)
|
||
response.__exit__ = MagicMock(side_effect=OSError("close failure"))
|
||
transport = MagicMock(return_value=response)
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable, match="network error"):
|
||
_client().ra_for(ISTIC_DOI)
|
||
|
||
|
||
def test_truncated_body_raises_unavailable():
|
||
"""IncompleteRead inherits HTTPException, not OSError — a truncated body
|
||
must degrade, never become a miss."""
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
resp = MagicMock()
|
||
resp.status = 200
|
||
resp.headers = {}
|
||
resp.geturl.return_value = "https://doi.org/doiRA/10.5555"
|
||
resp.read.side_effect = http.client.IncompleteRead(partial=b"{", expected=200)
|
||
resp.__enter__ = MagicMock(return_value=resp)
|
||
resp.__exit__ = MagicMock(return_value=None)
|
||
|
||
with patch("chinese_literature_client._safe_urlopen", return_value=resp):
|
||
with pytest.raises(ChineseLiteratureUnavailable, match="read failed"):
|
||
_client().ra_for(ISTIC_DOI)
|
||
|
||
|
||
def test_unparseable_200_body_raises_unavailable():
|
||
"""#331: an HTML error page served with 200 by a proxy/CDN must degrade, not
|
||
be recorded as an empty result."""
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
transport = FakeTransport([(_is_esearch, _ok(_body("error_5xx.html")))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().journal_is_indexed("Hecheng Ceshi Yi Xue Za Zhi")
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"body",
|
||
[
|
||
b'{"unexpected": true}',
|
||
b'{"esearchresult":{"idlist":[null]}}',
|
||
b'{"esearchresult":{"idlist":[42]}}',
|
||
b'{"esearchresult":{"idlist":[""]}}',
|
||
b'{"esearchresult":{"idlist":["0"]}}',
|
||
b'{"esearchresult":{"count":false,"idlist":[]}}',
|
||
b'{"esearchresult":{"count":"0","idlist":["999999901"]}}',
|
||
b'{"esearchresult":{"count":"1","idlist":[]}}',
|
||
(b'{"esearchresult":{"count":"' + b"9" * 5000 + b'","idlist":[]}}'),
|
||
b'{"esearchresult":{"count":"2","idlist":["999999901","999999901"]}}',
|
||
],
|
||
)
|
||
def test_unexpected_esearch_shape_raises_unavailable(body):
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
transport = FakeTransport([(_is_esearch, _ok(body))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().journal_is_indexed("Hecheng Ceshi Yi Xue Za Zhi")
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"body",
|
||
[
|
||
b'{"result": {"999999901": 42}}',
|
||
b'{"result": {"999999901": {"articleids": {}}}}',
|
||
b'{"result": {"999999901": {"articleids": [], "issn": {}}}}',
|
||
b'{"result": {"999999901": {"articleids": [], "title": false}}}',
|
||
b'{"result": {"999999901": {"articleids": [], "pubdate": false}}}',
|
||
b'{"result": {"999999901": {"articleids": [], "pubdate": "garbage"}}}',
|
||
'{"result": {"999999901": {"articleids": [], "pubdate": "٢٠٢٦"}}}'.encode(),
|
||
b'{"result": {"999999901": {"articleids": [], "source": []}}}',
|
||
b'{"result": {"999999901": {"articleids": [], "lang": false}}}',
|
||
b'{"result": {"999999901": {"articleids": [{"idtype": false, "value": "x"}]}}}',
|
||
b'{"result": {"999999901": {"articleids": [{"idtype": "doi", "value": false}]}}}',
|
||
b'{"result": {"999999901": {"articleids": [{"idtype": "doi", "value": "10.a/x"}, {"idtype": "doi", "value": "10.b/y"}]}}}',
|
||
b'{"result": {"uids": ["888888888"], "999999901": {"articleids": []}}}',
|
||
b'{"result": {"uids": [null], "999999901": {"articleids": []}}}',
|
||
b'{"result": {"uids": ["999999901"], "999999901": {"uid": "888888888", "articleids": []}}}',
|
||
b'{"result": {"uids": ["999999901"], "999999901": {"uid": 999999901, "articleids": []}}}',
|
||
],
|
||
)
|
||
def test_unexpected_esummary_shape_raises_unavailable(body):
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
transport = FakeTransport([(_is_esummary, _ok(body))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client()._esummary("999999901")
|
||
|
||
|
||
def test_resolve_propagates_unavailable_rather_than_returning_a_verdict():
|
||
"""fail-closed: `resolve` has no `unreachable` return value by design. An
|
||
outage must reach the caller as an exception so it can never be silently
|
||
rendered as a lookup result."""
|
||
from chinese_literature_client import ChineseLiteratureUnavailable
|
||
|
||
transport = FakeTransport([(_is_ra, _http_error(503))])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
with pytest.raises(ChineseLiteratureUnavailable):
|
||
_client().resolve(_istic_entry())
|
||
|
||
|
||
# ------------------------------------------------------------------ throttling
|
||
|
||
|
||
def test_throttle_uses_monotonic_clock(monkeypatch):
|
||
"""time.monotonic for elapsed measurement (NTP-safe), never time.time
|
||
(#128 §6). Aligns with arxiv/crossref/openalex/S2."""
|
||
monotonic_calls: list[int] = []
|
||
time_calls: list[int] = []
|
||
monkeypatch.setattr("chinese_literature_client.time.monotonic",
|
||
lambda: (monotonic_calls.append(1), 100.0)[1])
|
||
monkeypatch.setattr("chinese_literature_client.time.time",
|
||
lambda: (time_calls.append(1), 100.0)[1])
|
||
|
||
client = _client()
|
||
client._throttle("_last_doi_at", 0.2) # no prior request -> short-circuit
|
||
client._last_doi_at = 99.9
|
||
client._throttle("_last_doi_at", 0.2)
|
||
|
||
assert monotonic_calls, "throttle must read time.monotonic"
|
||
assert time_calls == [], "throttle must NOT read time.time (NTP-unsafe)"
|
||
|
||
|
||
def test_doi_and_ncbi_have_independent_throttle_anchors(monkeypatch):
|
||
"""Sharing one anchor would either over-throttle doi.org or under-throttle
|
||
NCBI, whose published expectations differ."""
|
||
sleeps: list[float] = []
|
||
monkeypatch.setattr("chinese_literature_client.time.sleep", sleeps.append)
|
||
clock = [1000.0]
|
||
monkeypatch.setattr("chinese_literature_client.time.monotonic", lambda: clock[0])
|
||
|
||
esearch_bodies = [_body("esearch_coverage_hit.json"),
|
||
_body("esearch_coverage_hit.json")]
|
||
transport = FakeTransport([
|
||
(_is_ra, _ok(_body("ra_istic.json"))),
|
||
(_is_esearch, lambda url: _ok(esearch_bodies.pop(0))(url)),
|
||
])
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
client = _client()
|
||
client.ra_for(ISTIC_DOI)
|
||
# A DOI request must not make the (independent) NCBI anchor wait.
|
||
client.journal_is_indexed("Hecheng Ceshi Yi Xue Za Zhi")
|
||
assert sleeps == []
|
||
# A second NCBI request on a frozen clock does pace at the NCBI floor.
|
||
client.journal_is_indexed("Hecheng Ceshi Yi Xue Za Zhi")
|
||
assert sleeps == [pytest.approx(0.34)]
|
||
|
||
|
||
# ------------------------------------------------------------ checklist shape
|
||
|
||
|
||
def test_every_applicable_non_matched_scenario_emits_exactly_one_checklist_row():
|
||
"""The checklist is a first-class deliverable, not a log side effect: it is
|
||
what turns 'nobody ever actually checked this reference' from an invisible
|
||
default into an item somebody has to sign off on."""
|
||
from chinese_literature_client import REASON_CODES
|
||
|
||
scenarios = [
|
||
([(_is_ra, _ok(_body("ra_istic.json"))),
|
||
(_is_doi_resolve, _http_error(404)),
|
||
(_is_handle, _ok(_body("handle_absent.json")))], _istic_entry(), {}),
|
||
([(_is_ra, _ok(_body("ra_cnki.json"))),
|
||
(_is_handle, _ok(_body("handle_exists.json")))],
|
||
_istic_entry(doi=CNKI_DOI), {}),
|
||
([(_is_esearch, _ok(_body("esearch_coverage_zero.json")))],
|
||
_coordinate_entry(), {"journal_map": TEST_JOURNAL_MAP}),
|
||
]
|
||
for routes, entry, kwargs in scenarios:
|
||
transport = FakeTransport(routes)
|
||
with patch("chinese_literature_client._safe_urlopen", transport):
|
||
result = _client(**kwargs).resolve(entry)
|
||
item = result["checklist_item"]
|
||
assert item is not None
|
||
assert item["reason_code"] in REASON_CODES
|
||
assert item["priority"] in {"P0", "P1", "P2", "P3"}
|
||
assert item["citation_key"] == entry["citation_key"]
|
||
assert item["human_result"] is None # the tool never fills the verdict
|
||
assert item["attempts"], "a row must record which sources were tried"
|
||
|
||
|
||
def test_priority_is_workload_ordering_not_a_suspicion_score():
|
||
"""Fabrication vocabulary is permitted only at P0. Mislabeling a real paper
|
||
by a real author costs far more than missing one bad citation."""
|
||
from chinese_literature_client import _PRIORITY_BY_REASON
|
||
|
||
assert _PRIORITY_BY_REASON["DOI_REFUTED"] == "P0"
|
||
assert _PRIORITY_BY_REASON["DOI_TITLE_MISMATCH"] == "P0"
|
||
assert _PRIORITY_BY_REASON["DOI_EXISTS_TITLE_UNVERIFIABLE"] == "P2"
|
||
assert _PRIORITY_BY_REASON["NO_ISSN_MAPPING"] == "P3"
|
||
non_p0 = {code for code, priority in _PRIORITY_BY_REASON.items()
|
||
if priority != "P0"}
|
||
assert "DOI_REFUTED" not in non_p0
|
||
|
||
|
||
def test_unknown_reason_code_is_rejected():
|
||
"""The reason-code set is closed; adding a member is a protocol-doc change."""
|
||
from chinese_literature_client import _checklist_item
|
||
|
||
with pytest.raises(ValueError):
|
||
_checklist_item(
|
||
reason_code="MADE_UP_CODE",
|
||
verdict_contribution="unresolvable",
|
||
entry={"citation_key": "x"},
|
||
attempts=[],
|
||
human_action="",
|
||
)
|
||
|
||
|
||
def test_no_forbidden_upstream_host_appears_in_client_code():
|
||
"""Zero-scraping red line, pinned executably: CNKI / Wanfang / VIP / chndoi
|
||
hosts must never become upstreams here. Guards against a well-intentioned
|
||
later edit 'just adding a title lookup'.
|
||
|
||
Scanned over the AST's non-docstring string constants rather than raw text:
|
||
prose EXPLAINING why we refuse to scrape chndoi.org is exactly the comment
|
||
a future editor most needs to read, so a raw-text scan would pressure
|
||
someone to delete the warning in order to pass the test."""
|
||
import ast
|
||
|
||
source = (REPO_ROOT / "scripts" / "chinese_literature_client.py").read_text(
|
||
encoding="utf-8")
|
||
tree = ast.parse(source)
|
||
docstrings = set()
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef,
|
||
ast.AsyncFunctionDef)):
|
||
body = getattr(node, "body", None)
|
||
if (body and isinstance(body[0], ast.Expr)
|
||
and isinstance(body[0].value, ast.Constant)
|
||
and isinstance(body[0].value.value, str)):
|
||
docstrings.add(id(body[0].value))
|
||
|
||
literals = [
|
||
node.value for node in ast.walk(tree)
|
||
if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
||
and id(node) not in docstrings
|
||
]
|
||
# Sanity: the scan must actually see the real endpoint constants, otherwise
|
||
# it would pass vacuously.
|
||
assert any("hdl.handle.net" in literal for literal in literals)
|
||
for host in ("cnki.net", "wanfangdata", "cqvip.com", "chndoi.org"):
|
||
for literal in literals:
|
||
assert host not in literal, f"forbidden upstream host {host!r} in client"
|