Files
wehub-resource-sync 2860fb5d18
Security / Dependency review (push) Has been skipped
Scorecard / Scorecard analysis (push) Failing after 0s
Validate / eval (push) Failing after 0s
Security / Dependency audit (push) Failing after 1s
Security / Secret scan (push) Failing after 1s
Validate / tests (push) Failing after 0s
Validate / mcp-tests (push) Failing after 1s
GitHub Actions Security Analysis with zizmor 🌈 / zizmor (push) Failing after 1s
Security / SAST scan (push) Failing after 13m52s
chore: import upstream snapshot with attribution
2026-07-13 12:05:33 +08:00

168 lines
6.4 KiB
Python

"""Tests for the arXiv source adapter (lib/arxiv.py).
Covers query construction (quoted phrase + relevance sort), the recency
cutoff that doubles as off-topic gating, field mapping, and graceful
degradation when the binary is absent.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from lib import arxiv
NOW = datetime(2026, 6, 27, tzinfo=timezone.utc)
def _entry(title, published, summary="An abstract.", authors=("Ada Lovelace",), abs_url="https://arxiv.org/abs/2606.00001v1"):
return {
"id": "http://arxiv.org/abs/2606.00001v1",
"title": title,
"summary": summary,
"published": published,
"authors": [{"name": a} for a in authors],
"categories": [{"term": "cs.AI"}],
"links": [
{"rel": "alternate", "href": abs_url, "type": "text/html"},
{"rel": "related", "href": "https://arxiv.org/pdf/2606.00001v1", "title": "pdf"},
],
}
# ---- query construction ----
def test_search_query_is_quoted_phrase_relevance_sorted():
args = arxiv._build_search_args("AI coding agents", 10)
joined = " ".join(args)
assert '--search-query' in args
assert 'all:"AI coding agents"' in args # quoted phrase
assert "--sort-by" in args and "relevance" in args # NOT submittedDate
assert "submittedDate" not in joined
def test_search_query_strips_inner_quotes():
q = arxiv._build_search_query('say "hello" world')
assert q == 'all:"say hello world"'
# ---- envelope extraction ----
def test_extract_entries_handles_nested_results_envelope():
data = {"meta": {"source": "live"}, "results": {"entries": [{"title": "X"}]}}
entries = arxiv._extract_entries(data)
assert len(entries) == 1 and entries[0]["title"] == "X"
# ---- happy path + field mapping ----
def test_happy_path_maps_fields():
recent = (NOW - timedelta(days=10)).strftime("%Y-%m-%dT12:00:00Z")
resp = {"results": [_entry("Agent Memory as a Database", recent, authors=("A", "B"))]}
items = arxiv.parse_arxiv_response(resp, query="agent memory", today=NOW)
assert len(items) == 1
it = items[0]
assert it["title"] == "Agent Memory as a Database"
assert it["url"] == "https://arxiv.org/abs/2606.00001v1" # alternate, not pdf
assert it["date"] == (NOW - timedelta(days=10)).date().isoformat()
assert it["authors"] == ["A", "B"]
assert it["author"] == "A et al." # multi-author label
def test_url_prefers_alternate_over_pdf():
recent = (NOW - timedelta(days=5)).strftime("%Y-%m-%dT12:00:00Z")
resp = {"results": [_entry("T", recent)]}
items = arxiv.parse_arxiv_response(resp, query="t", today=NOW)
assert "/pdf/" not in items[0]["url"]
assert "/abs/" in items[0]["url"]
# ---- recency cutoff (also off-topic gating) ----
def test_recency_cutoff_drops_stale_paper():
stale = (NOW - timedelta(days=arxiv.RECENCY_DAYS + 30)).strftime("%Y-%m-%dT12:00:00Z")
resp = {"results": [_entry("Old Paper", stale)]}
items = arxiv.parse_arxiv_response(resp, query="old paper", today=NOW)
assert items == []
def test_off_topic_control_drops_via_recency():
# The "Golden State Warriors" keyword match is a 2017 stats paper -- the
# recency cutoff is what keeps arXiv quiet on non-research topics.
old = "2017-06-12T12:00:00Z"
resp = {"results": [_entry("Do Steph Curry and Klay Thompson Have Hot Hands?", old)]}
items = arxiv.parse_arxiv_response(resp, query="Golden State Warriors", today=NOW)
assert items == []
def test_unparseable_or_missing_date_is_dropped():
resp = {"results": [_entry("No Date", None), _entry("Bad Date", "not-a-date")]}
items = arxiv.parse_arxiv_response(resp, query="x", today=NOW)
assert items == []
def test_future_dated_entry_is_dropped():
future = (NOW + timedelta(days=5)).strftime("%Y-%m-%dT12:00:00Z")
resp = {"results": [_entry("Future", future)]}
items = arxiv.parse_arxiv_response(resp, query="future", today=NOW)
assert items == []
# ---- error / degradation paths ----
def test_binary_absent_returns_empty(monkeypatch):
monkeypatch.setattr(arxiv.shutil, "which", lambda _bin: None)
resp = arxiv.search_arxiv("anything", "2026-06-01", "2026-06-27")
assert resp["results"] == []
assert "error" in resp
def test_empty_topic_returns_empty():
assert arxiv.search_arxiv(" ", "2026-06-01", "2026-06-27") == {"results": []}
def test_parse_handles_non_list_results():
assert arxiv.parse_arxiv_response({"results": "oops"}, query="x", today=NOW) == []
assert arxiv.parse_arxiv_response({}, query="x", today=NOW) == []
def test_quote_only_topic_returns_empty():
# A topic of only quote characters cleans to an empty phrase; don't search.
assert arxiv.search_arxiv('"', "2026-06-01", "2026-06-27") == {"results": []}
def test_same_day_future_timestamp_is_kept():
# A paper announced later the same UTC day yields age_days == -1; the
# one-day grace keeps it rather than dropping it as "future".
later_today = NOW.replace(hour=23, minute=59).strftime("%Y-%m-%dT%H:%M:00Z")
resp = {"results": [_entry("Fresh paper", later_today)]}
items = arxiv.parse_arxiv_response(resp, query="fresh paper", today=NOW.replace(hour=1))
assert len(items) == 1
class _Proc:
def __init__(self, rc, out, err=""):
self.returncode, self.stdout, self.stderr = rc, out, err
def test_run_cli_flattens_nested_envelope(monkeypatch):
monkeypatch.setattr(arxiv, "_is_available", lambda: True)
payload = '{"meta":{},"results":{"entries":[{"title":"X"}]}}'
monkeypatch.setattr(arxiv.subproc, "run_with_timeout", lambda cmd, timeout: _Proc(0, payload))
resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27")
assert resp["results"] == [{"title": "X"}]
def test_run_cli_nonzero_exit_returns_error(monkeypatch):
monkeypatch.setattr(arxiv, "_is_available", lambda: True)
monkeypatch.setattr(arxiv.subproc, "run_with_timeout", lambda cmd, timeout: _Proc(1, "", "boom\nmore"))
resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27")
assert resp["results"] == [] and "boom" in resp["error"]
def test_run_cli_bad_json_returns_error(monkeypatch):
monkeypatch.setattr(arxiv, "_is_available", lambda: True)
monkeypatch.setattr(arxiv.subproc, "run_with_timeout", lambda cmd, timeout: _Proc(0, "not json"))
resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27")
assert resp["results"] == [] and "error" in resp