@@ -530,6 +530,7 @@ def fake_resolve_requested_dates_for_plan(
530530 t_product_start : float ,
531531 catch_up_to_latest : bool = False ,
532532 lock = None ,
533+ ** kwargs ,
533534 ) -> tuple [list [str ], bool ]:
534535 if plan .name == "stock-trading-data" :
535536 return ([], False )
@@ -589,6 +590,7 @@ def fake_resolve_requested_dates_for_plan(
589590 t_product_start : float ,
590591 catch_up_to_latest : bool = False ,
591592 lock = None ,
593+ ** kwargs ,
592594 ) -> tuple [list [str ], bool ]:
593595 return (["2026-02-09" ], False )
594596
@@ -627,5 +629,75 @@ def fake_process_product(
627629 self .assertEqual (["error" ], [item .status for item in report .products ])
628630
629631
632+ class TestApiDateCache (unittest .TestCase ):
633+ """_resolve_requested_dates_for_plan 应优先使用缓存。"""
634+
635+ def setUp (self ):
636+ self ._tmpdir = tempfile .TemporaryDirectory ()
637+ self .root = Path (self ._tmpdir .name )
638+
639+ def tearDown (self ):
640+ self ._tmpdir .cleanup ()
641+
642+ def _ctx (self ):
643+ return CommandContext (
644+ run_id = "test" , data_root = self .root ,
645+ dry_run = False , stop_on_error = False ,
646+ )
647+
648+ def _plan (self , name = "stock-trading-data" ):
649+ return build_product_plan ([name ])[0 ]
650+
651+ def _report (self ):
652+ return _new_report ("test" , mode = "network" )
653+
654+ @patch ("quantclass_sync_internal.orchestrator.should_skip_by_timestamp" , return_value = True )
655+ @patch ("quantclass_sync_internal.orchestrator.get_latest_times" )
656+ def test_cache_hit_skips_http (self , mock_get_latest , mock_skip ):
657+ """缓存新鲜时不调用 get_latest_times。"""
658+ from datetime import datetime
659+ fresh_checked_at = datetime .now ().strftime ("%Y-%m-%dT%H:%M:%S" )
660+ cache = {"stock-trading-data" : ("2026-03-18" , fresh_checked_at )}
661+ queue , skipped = _resolve_requested_dates_for_plan (
662+ plan = self ._plan (), command_ctx = self ._ctx (),
663+ hid = "hid" , headers = {"api-key" : "k" },
664+ requested_date_time = "" , force_update = False ,
665+ report = self ._report (), t_product_start = time .time (),
666+ api_date_cache = cache ,
667+ )
668+ mock_get_latest .assert_not_called ()
669+
670+ @patch ("quantclass_sync_internal.orchestrator.should_skip_by_timestamp" , return_value = True )
671+ @patch ("quantclass_sync_internal.orchestrator.get_latest_times" , return_value = ["2026-03-18" ])
672+ def test_cache_expired_falls_through (self , mock_get_latest , mock_skip ):
673+ """缓存过期时回退 HTTP。"""
674+ from datetime import datetime , timedelta
675+ from quantclass_sync_internal .constants import API_DATE_CACHE_TTL_SECONDS
676+ expired_time = (datetime .now () - timedelta (seconds = API_DATE_CACHE_TTL_SECONDS + 60 ))
677+ old_checked_at = expired_time .strftime ("%Y-%m-%dT%H:%M:%S" )
678+ cache = {"stock-trading-data" : ("2026-03-18" , old_checked_at )}
679+ _resolve_requested_dates_for_plan (
680+ plan = self ._plan (), command_ctx = self ._ctx (),
681+ hid = "hid" , headers = {"api-key" : "k" },
682+ requested_date_time = "" , force_update = False ,
683+ report = self ._report (), t_product_start = time .time (),
684+ api_date_cache = cache ,
685+ )
686+ mock_get_latest .assert_called_once ()
687+
688+ @patch ("quantclass_sync_internal.orchestrator.should_skip_by_timestamp" , return_value = True )
689+ @patch ("quantclass_sync_internal.orchestrator.get_latest_times" , return_value = ["2026-03-18" ])
690+ def test_cache_miss_falls_through (self , mock_get_latest , mock_skip ):
691+ """产品不在缓存中时回退 HTTP。"""
692+ _resolve_requested_dates_for_plan (
693+ plan = self ._plan (), command_ctx = self ._ctx (),
694+ hid = "hid" , headers = {"api-key" : "k" },
695+ requested_date_time = "" , force_update = False ,
696+ report = self ._report (), t_product_start = time .time (),
697+ api_date_cache = {},
698+ )
699+ mock_get_latest .assert_called_once ()
700+
701+
630702if __name__ == "__main__" :
631703 unittest .main ()
0 commit comments