| 1 | """Tests for the arXiv source adapter (lib/arxiv.py). |
| 2 | |
| 3 | Covers query construction (quoted phrase + relevance sort), the recency |
| 4 | cutoff that doubles as off-topic gating, field mapping, and graceful |
| 5 | degradation when the binary is absent. |
| 6 | """ |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | from datetime import datetime, timedelta, timezone |
| 11 | |
| 12 | from lib import arxiv |
| 13 | |
| 14 | |
| 15 | NOW = datetime(2026, 6, 27, tzinfo=timezone.utc) |
| 16 | |
| 17 | |
| 18 | def _entry(title, published, summary="An abstract.", authors=("Ada Lovelace",), abs_url="https://arxiv.org/abs/2606.00001v1"): |
| 19 | return { |
| 20 | "id": "http://arxiv.org/abs/2606.00001v1", |
| 21 | "title": title, |
| 22 | "summary": summary, |
| 23 | "published": published, |
| 24 | "authors": [{"name": a} for a in authors], |
| 25 | "categories": [{"term": "cs.AI"}], |
| 26 | "links": [ |
| 27 | {"rel": "alternate", "href": abs_url, "type": "text/html"}, |
| 28 | {"rel": "related", "href": "https://arxiv.org/pdf/2606.00001v1", "title": "pdf"}, |
| 29 | ], |
| 30 | } |
| 31 | |
| 32 | |
| 33 | # ---- query construction ---- |
| 34 | |
| 35 | def test_search_query_is_quoted_phrase_relevance_sorted(): |
| 36 | args = arxiv._build_search_args("AI coding agents", 10) |
| 37 | joined = " ".join(args) |
| 38 | assert '--search-query' in args |
| 39 | assert 'all:"AI coding agents"' in args # quoted phrase |
| 40 | assert "--sort-by" in args and "relevance" in args # NOT submittedDate |
| 41 | assert "submittedDate" not in joined |
| 42 | |
| 43 | |
| 44 | def test_search_query_strips_inner_quotes(): |
| 45 | q = arxiv._build_search_query('say "hello" world') |
| 46 | assert q == 'all:"say hello world"' |
| 47 | |
| 48 | |
| 49 | def test_unquoted_fallback_search_args_conjoin_terms_at_command_boundary(): |
| 50 | args = arxiv._build_search_args("AI video generation advances", 10, quoted=False) |
| 51 | assert args == [ |
| 52 | "arxiv-pp-cli", |
| 53 | "query", |
| 54 | "--search-query", |
| 55 | 'all:"AI" AND all:"video" AND all:"generation" AND all:"advances"', |
| 56 | "--sort-by", |
| 57 | "relevance", |
| 58 | "--max-results", |
| 59 | "10", |
| 60 | "--agent", |
| 61 | ] |
| 62 | |
| 63 | |
| 64 | def test_unquoted_fallback_quotes_operator_and_colon_terms_at_command_boundary(): |
| 65 | args = arxiv._build_search_args("AI AND OR title:video", 5, quoted=False) |
| 66 | assert args[args.index("--search-query") + 1] == ( |
| 67 | 'all:"AI" AND all:"AND" AND all:"OR" AND all:"title:video"' |
| 68 | ) |
| 69 | |
| 70 | |
| 71 | # ---- envelope extraction ---- |
| 72 | |
| 73 | def test_extract_entries_handles_nested_results_envelope(): |
| 74 | data = {"meta": {"source": "live"}, "results": {"entries": [{"title": "X"}]}} |
| 75 | entries = arxiv._extract_entries(data) |
| 76 | assert len(entries) == 1 and entries[0]["title"] == "X" |
| 77 | |
| 78 | |
| 79 | # ---- happy path + field mapping ---- |
| 80 | |
| 81 | def test_happy_path_maps_fields(): |
| 82 | recent = (NOW - timedelta(days=10)).strftime("%Y-%m-%dT12:00:00Z") |
| 83 | resp = {"results": [_entry("Agent Memory as a Database", recent, authors=("A", "B"))]} |
| 84 | items = arxiv.parse_arxiv_response(resp, query="agent memory", today=NOW) |
| 85 | assert len(items) == 1 |
| 86 | it = items[0] |
| 87 | assert it["title"] == "Agent Memory as a Database" |
| 88 | assert it["url"] == "https://arxiv.org/abs/2606.00001v1" # alternate, not pdf |
| 89 | assert it["date"] == (NOW - timedelta(days=10)).date().isoformat() |
| 90 | assert it["authors"] == ["A", "B"] |
| 91 | assert it["author"] == "A et al." # multi-author label |
| 92 | |
| 93 | |
| 94 | def test_url_prefers_alternate_over_pdf(): |
| 95 | recent = (NOW - timedelta(days=5)).strftime("%Y-%m-%dT12:00:00Z") |
| 96 | resp = {"results": [_entry("T", recent)]} |
| 97 | items = arxiv.parse_arxiv_response(resp, query="t", today=NOW) |
| 98 | assert "/pdf/" not in items[0]["url"] |
| 99 | assert "/abs/" in items[0]["url"] |
| 100 | |
| 101 | |
| 102 | # ---- recency cutoff (also off-topic gating) ---- |
| 103 | |
| 104 | def test_recency_cutoff_drops_stale_paper(): |
| 105 | stale = (NOW - timedelta(days=arxiv.RECENCY_DAYS + 30)).strftime("%Y-%m-%dT12:00:00Z") |
| 106 | resp = {"results": [_entry("Old Paper", stale)]} |
| 107 | items = arxiv.parse_arxiv_response(resp, query="old paper", today=NOW) |
| 108 | assert items == [] |
| 109 | |
| 110 | |
| 111 | def test_off_topic_control_drops_via_recency(): |
| 112 | # The "Golden State Warriors" keyword match is a 2017 stats paper -- the |
| 113 | # recency cutoff is what keeps arXiv quiet on non-research topics. |
| 114 | old = "2017-06-12T12:00:00Z" |
| 115 | resp = {"results": [_entry("Do Steph Curry and Klay Thompson Have Hot Hands?", old)]} |
| 116 | items = arxiv.parse_arxiv_response(resp, query="Golden State Warriors", today=NOW) |
| 117 | assert items == [] |
| 118 | |
| 119 | |
| 120 | def test_unparseable_or_missing_date_is_dropped(): |
| 121 | resp = {"results": [_entry("No Date", None), _entry("Bad Date", "not-a-date")]} |
| 122 | items = arxiv.parse_arxiv_response(resp, query="x", today=NOW) |
| 123 | assert items == [] |
| 124 | |
| 125 | |
| 126 | def test_future_dated_entry_is_dropped(): |
| 127 | future = (NOW + timedelta(days=5)).strftime("%Y-%m-%dT12:00:00Z") |
| 128 | resp = {"results": [_entry("Future", future)]} |
| 129 | items = arxiv.parse_arxiv_response(resp, query="future", today=NOW) |
| 130 | assert items == [] |
| 131 | |
| 132 | |
| 133 | # ---- error / degradation paths ---- |
| 134 | |
| 135 | def test_binary_absent_returns_empty(monkeypatch): |
| 136 | monkeypatch.setattr(arxiv.shutil, "which", lambda _bin: None) |
| 137 | resp = arxiv.search_arxiv("anything", "2026-06-01", "2026-06-27") |
| 138 | assert resp["results"] == [] |
| 139 | assert "error" in resp |
| 140 | |
| 141 | |
| 142 | def test_empty_topic_returns_empty(): |
| 143 | assert arxiv.search_arxiv(" ", "2026-06-01", "2026-06-27") == {"results": []} |
| 144 | |
| 145 | |
| 146 | def test_parse_handles_non_list_results(): |
| 147 | assert arxiv.parse_arxiv_response({"results": "oops"}, query="x", today=NOW) == [] |
| 148 | assert arxiv.parse_arxiv_response({}, query="x", today=NOW) == [] |
| 149 | |
| 150 | |
| 151 | def test_quote_only_topic_returns_empty(): |
| 152 | # A topic of only quote characters cleans to an empty phrase; don't search. |
| 153 | assert arxiv.search_arxiv('"', "2026-06-01", "2026-06-27") == {"results": []} |
| 154 | |
| 155 | |
| 156 | def test_same_day_future_timestamp_is_kept(): |
| 157 | # A paper announced later the same UTC day yields age_days == -1; the |
| 158 | # one-day grace keeps it rather than dropping it as "future". |
| 159 | later_today = NOW.replace(hour=23, minute=59).strftime("%Y-%m-%dT%H:%M:00Z") |
| 160 | resp = {"results": [_entry("Fresh paper", later_today)]} |
| 161 | items = arxiv.parse_arxiv_response(resp, query="fresh paper", today=NOW.replace(hour=1)) |
| 162 | assert len(items) == 1 |
| 163 | |
| 164 | |
| 165 | class _Proc: |
| 166 | def __init__(self, rc, out, err=""): |
| 167 | self.returncode, self.stdout, self.stderr = rc, out, err |
| 168 | |
| 169 | |
| 170 | def test_run_cli_flattens_nested_envelope(monkeypatch): |
| 171 | monkeypatch.setattr(arxiv, "_is_available", lambda: True) |
| 172 | payload = '{"meta":{},"results":{"entries":[{"title":"X"}]}}' |
| 173 | monkeypatch.setattr(arxiv.subproc, "run_with_timeout", lambda cmd, timeout: _Proc(0, payload)) |
| 174 | resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27") |
| 175 | assert resp["results"] == [{"title": "X"}] |
| 176 | |
| 177 | |
| 178 | def test_run_cli_nonzero_exit_returns_error(monkeypatch): |
| 179 | monkeypatch.setattr(arxiv, "_is_available", lambda: True) |
| 180 | monkeypatch.setattr(arxiv.subproc, "run_with_timeout", lambda cmd, timeout: _Proc(1, "", "boom\nmore")) |
| 181 | resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27") |
| 182 | assert resp["results"] == [] and "boom" in resp["error"] |
| 183 | |
| 184 | |
| 185 | def test_run_cli_bad_json_returns_error(monkeypatch): |
| 186 | monkeypatch.setattr(arxiv, "_is_available", lambda: True) |
| 187 | monkeypatch.setattr(arxiv.subproc, "run_with_timeout", lambda cmd, timeout: _Proc(0, "not json")) |
| 188 | resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27") |
| 189 | assert resp["results"] == [] and "error" in resp |
| 190 | |
| 191 | |
| 192 | def test_empty_stdout_returns_error_without_retry(monkeypatch): |
| 193 | monkeypatch.setattr(arxiv, "_is_available", lambda: True) |
| 194 | calls = [] |
| 195 | |
| 196 | def fake_run(cmd, timeout): |
| 197 | calls.append(cmd) |
| 198 | return _Proc(0, "") |
| 199 | |
| 200 | monkeypatch.setattr(arxiv.subproc, "run_with_timeout", fake_run) |
| 201 | resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27") |
| 202 | assert resp == {"results": [], "error": "empty stdout"} |
| 203 | assert len(calls) == 1 |
| 204 | |
| 205 | |
| 206 | def test_unrecognized_json_returns_error_without_retry(monkeypatch): |
| 207 | monkeypatch.setattr(arxiv, "_is_available", lambda: True) |
| 208 | calls = [] |
| 209 | |
| 210 | def fake_run(cmd, timeout): |
| 211 | calls.append(cmd) |
| 212 | return _Proc(0, '{"status":"ok"}') |
| 213 | |
| 214 | monkeypatch.setattr(arxiv.subproc, "run_with_timeout", fake_run) |
| 215 | resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27") |
| 216 | assert resp == {"results": [], "error": "unrecognized JSON response"} |
| 217 | assert len(calls) == 1 |
| 218 | |
| 219 | |
| 220 | def test_recognized_empty_list_retries(monkeypatch): |
| 221 | monkeypatch.setattr(arxiv, "_is_available", lambda: True) |
| 222 | calls = [] |
| 223 | |
| 224 | def fake_run(cmd, timeout): |
| 225 | calls.append(cmd) |
| 226 | return _Proc(0, "[]") |
| 227 | |
| 228 | monkeypatch.setattr(arxiv.subproc, "run_with_timeout", fake_run) |
| 229 | resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27") |
| 230 | assert resp == {"results": []} |
| 231 | assert len(calls) == 2 |
| 232 | |
| 233 | |
| 234 | # ---- unquoted-retry on zero results (#908) ---- |
| 235 | |
| 236 | def test_zero_result_quoted_query_retries_unquoted_and_finds_results(monkeypatch): |
| 237 | """A natural-language multi-word topic matches nothing as an exact |
| 238 | phrase, but the unquoted retry finds it -- the fix for #908.""" |
| 239 | monkeypatch.setattr(arxiv, "_is_available", lambda: True) |
| 240 | calls = [] |
| 241 | |
| 242 | def fake_run(cmd, timeout): |
| 243 | calls.append(cmd) |
| 244 | query = cmd[cmd.index("--search-query") + 1] |
| 245 | if query == 'all:"AI video generation advances"': |
| 246 | return _Proc(0, '{"results":{"entries":[]}}') |
| 247 | return _Proc(0, '{"results":{"entries":[{"title":"AI video generation advances"}]}}') |
| 248 | |
| 249 | monkeypatch.setattr(arxiv.subproc, "run_with_timeout", fake_run) |
| 250 | resp = arxiv.search_arxiv("AI video generation advances", "2026-06-01", "2026-06-27") |
| 251 | assert resp["results"] == [{"title": "AI video generation advances"}] |
| 252 | assert len(calls) == 2 |
| 253 | assert 'all:"AI video generation advances"' in calls[0] |
| 254 | assert 'all:"AI" AND all:"video" AND all:"generation" AND all:"advances"' in calls[1] |
| 255 | |
| 256 | |
| 257 | def test_zero_result_quoted_query_retry_also_empty_returns_empty(monkeypatch): |
| 258 | monkeypatch.setattr(arxiv, "_is_available", lambda: True) |
| 259 | calls = [] |
| 260 | |
| 261 | def fake_run(cmd, timeout): |
| 262 | calls.append(cmd) |
| 263 | return _Proc(0, '{"results":{"entries":[]}}') |
| 264 | |
| 265 | monkeypatch.setattr(arxiv.subproc, "run_with_timeout", fake_run) |
| 266 | resp = arxiv.search_arxiv("truly obscure nonsense topic", "2026-06-01", "2026-06-27") |
| 267 | assert resp["results"] == [] |
| 268 | assert "error" not in resp |
| 269 | assert len(calls) == 2 |
| 270 | |
| 271 | |
| 272 | def test_real_cli_error_does_not_trigger_unquoted_retry(monkeypatch): |
| 273 | """A genuine failure (nonzero exit) must not retry -- only a clean |
| 274 | zero-result success should (R6).""" |
| 275 | monkeypatch.setattr(arxiv, "_is_available", lambda: True) |
| 276 | calls = [] |
| 277 | |
| 278 | def fake_run(cmd, timeout): |
| 279 | calls.append(cmd) |
| 280 | return _Proc(1, "", "boom") |
| 281 | |
| 282 | monkeypatch.setattr(arxiv.subproc, "run_with_timeout", fake_run) |
| 283 | resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27") |
| 284 | assert resp["results"] == [] and "boom" in resp["error"] |
| 285 | assert len(calls) == 1 |
| 286 |