@@ -39,19 +39,20 @@ class TestFetchTopPapers:
3939 def test_sorts_by_upvotes_desc (self ) -> None :
4040 raw = [_entry ("2605.01000" , "low" , 1 ), _entry ("2605.02000" , "high" , 100 ),
4141 _entry ("2605.03000" , "mid" , 50 )]
42- out = fetch_top_papers ("2026-05-13" , limit = 3 , raw_json = raw )
42+ effective_date , out = fetch_top_papers ("2026-05-13" , limit = 3 , raw_json = raw )
43+ assert effective_date == "2026-05-13" # raw_json passed → no fallback
4344 assert [p .upvotes for p in out ] == [100 , 50 , 1 ]
4445 assert out [0 ].arxiv_id == "2605.02000"
4546
4647 def test_ties_break_by_arxiv_id (self ) -> None :
4748 # Deterministic tie-break so reruns + tests are stable.
4849 raw = [_entry ("2605.09000" , "B" , 10 ), _entry ("2605.01000" , "A" , 10 )]
49- out = fetch_top_papers ("2026-05-13" , limit = 2 , raw_json = raw )
50+ _ , out = fetch_top_papers ("2026-05-13" , limit = 2 , raw_json = raw )
5051 assert [p .arxiv_id for p in out ] == ["2605.01000" , "2605.09000" ]
5152
5253 def test_limit_truncates (self ) -> None :
5354 raw = [_entry (f"2605.{ i :05d} " , f"t{ i } " , i ) for i in range (20 )]
54- out = fetch_top_papers ("2026-05-13" , limit = 5 , raw_json = raw )
55+ _ , out = fetch_top_papers ("2026-05-13" , limit = 5 , raw_json = raw )
5556 assert len (out ) == 5
5657 # newest upvotes first
5758 assert out [0 ].upvotes == 19
@@ -65,12 +66,12 @@ def test_malformed_entries_skipped(self) -> None:
6566 {"paper" : {"id" : "2605.02000" , "title" : "" , "upvotes" : 99 }}, # missing title
6667 "not a dict" , # type error
6768 ]
68- out = fetch_top_papers ("2026-05-13" , limit = 10 , raw_json = raw )
69+ _ , out = fetch_top_papers ("2026-05-13" , limit = 10 , raw_json = raw )
6970 assert [p .arxiv_id for p in out ] == ["2605.01000" ]
7071
7172 def test_non_int_upvotes_coerced_to_zero (self ) -> None :
7273 raw = [{"paper" : {"id" : "2605.0" , "title" : "weird" , "upvotes" : "many" }}]
73- out = fetch_top_papers ("2026-05-13" , limit = 1 , raw_json = raw )
74+ _ , out = fetch_top_papers ("2026-05-13" , limit = 1 , raw_json = raw )
7475 assert out [0 ].upvotes == 0
7576
7677
@@ -131,3 +132,108 @@ def test_no_papers_to_file_yields_empty_result(self) -> None:
131132 assert result .fetched == 0
132133 assert result .filed == []
133134 assert result .skipped == []
135+
136+
137+ class TestDateFallback :
138+ """Tests for the date-bucket fallback chain added in the post-spec-010 fix.
139+
140+ HF daily-papers buckets are populated by HF's editorial pipeline in the
141+ late afternoon UTC. Running at 23:59 UTC + cron schedule drift can put
142+ the request past midnight (the new day's bucket is empty → HTTP 400).
143+ """
144+
145+ def test_today_utc_defaults_to_yesterday (self ) -> None :
146+ from datetime import datetime , timedelta , timezone
147+
148+ from llmxive .hf_daily_papers import _today_utc
149+
150+ expected = (datetime .now (timezone .utc ) - timedelta (days = 1 )).strftime ("%Y-%m-%d" )
151+ assert _today_utc () == expected
152+
153+ def test_fetch_falls_back_on_400 (self , monkeypatch ) -> None :
154+ """When the requested date returns 400, walk back one day and retry."""
155+ import urllib .error
156+
157+ from llmxive .hf_daily_papers import _fetch_daily_json
158+
159+ calls : list [str ] = []
160+
161+ def fake_one (date : str , * , timeout : float = 30.0 ):
162+ calls .append (date )
163+ if date == "2026-05-15" :
164+ raise urllib .error .HTTPError (
165+ url = "x" , code = 400 , msg = "Bad Request" , hdrs = None , fp = None ,
166+ )
167+ return [{"paper" : {"id" : "2605.fallback" , "title" : "ok" , "upvotes" : 1 }}]
168+
169+ monkeypatch .setattr (
170+ "llmxive.hf_daily_papers._fetch_daily_json_one" , fake_one
171+ )
172+ effective , data = _fetch_daily_json ("2026-05-15" , fallback_days = 1 )
173+ assert effective == "2026-05-14"
174+ assert calls == ["2026-05-15" , "2026-05-14" ]
175+ assert data [0 ]["paper" ]["id" ] == "2605.fallback"
176+
177+ def test_fetch_falls_back_on_404 (self , monkeypatch ) -> None :
178+ """404 (alternative HF response shape) also triggers fallback."""
179+ import urllib .error
180+
181+ from llmxive .hf_daily_papers import _fetch_daily_json
182+
183+ def fake_one (date : str , * , timeout : float = 30.0 ):
184+ if date == "2026-05-15" :
185+ raise urllib .error .HTTPError (
186+ url = "x" , code = 404 , msg = "Not Found" , hdrs = None , fp = None ,
187+ )
188+ return []
189+
190+ monkeypatch .setattr (
191+ "llmxive.hf_daily_papers._fetch_daily_json_one" , fake_one
192+ )
193+ effective , data = _fetch_daily_json ("2026-05-15" , fallback_days = 1 )
194+ assert effective == "2026-05-14"
195+ assert data == []
196+
197+ def test_fetch_does_not_swallow_5xx (self , monkeypatch ) -> None :
198+ """5xx errors should propagate — they indicate transient HF issues,
199+ not a date-bucket problem."""
200+ import urllib .error
201+
202+ import pytest
203+
204+ from llmxive .hf_daily_papers import _fetch_daily_json
205+
206+ def fake_one (date : str , * , timeout : float = 30.0 ):
207+ raise urllib .error .HTTPError (
208+ url = "x" , code = 503 , msg = "Service Unavailable" , hdrs = None , fp = None ,
209+ )
210+
211+ monkeypatch .setattr (
212+ "llmxive.hf_daily_papers._fetch_daily_json_one" , fake_one
213+ )
214+ with pytest .raises (urllib .error .HTTPError ) as excinfo :
215+ _fetch_daily_json ("2026-05-15" , fallback_days = 1 )
216+ assert excinfo .value .code == 503
217+
218+ def test_fetch_raises_when_all_fallback_attempts_400 (
219+ self , monkeypatch
220+ ) -> None :
221+ """If every date in the fallback chain returns 400, raise the last
222+ HTTPError so the caller can surface a meaningful failure."""
223+ import urllib .error
224+
225+ import pytest
226+
227+ from llmxive .hf_daily_papers import _fetch_daily_json
228+
229+ def fake_one (date : str , * , timeout : float = 30.0 ):
230+ raise urllib .error .HTTPError (
231+ url = "x" , code = 400 , msg = "Bad Request" , hdrs = None , fp = None ,
232+ )
233+
234+ monkeypatch .setattr (
235+ "llmxive.hf_daily_papers._fetch_daily_json_one" , fake_one
236+ )
237+ with pytest .raises (urllib .error .HTTPError ) as excinfo :
238+ _fetch_daily_json ("2026-05-15" , fallback_days = 2 )
239+ assert excinfo .value .code == 400
0 commit comments