Queue stale weather refreshes from user paths
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from web.cache_warmer_service import CacheWarmer, build_priority_city_batch
|
||||
from web.cache_warmer_service import CacheWarmer, build_default_cache_warmer, build_priority_city_batch
|
||||
|
||||
|
||||
def test_priority_city_batch_prefers_local_active_hours_over_night():
|
||||
@@ -51,6 +51,37 @@ def test_cache_warmer_warms_scan_and_city_panel_without_force_refresh():
|
||||
assert city_calls == [("alpha day", False)]
|
||||
|
||||
|
||||
def test_default_cache_warmer_enqueues_city_refresh_without_direct_panel_refresh(monkeypatch):
|
||||
import web.cache_warmer_service as cache_warmer_service
|
||||
from web.services import city_runtime
|
||||
|
||||
enqueued = []
|
||||
|
||||
class FakeDB:
|
||||
@staticmethod
|
||||
def enqueue_observation_refresh_request(**kwargs):
|
||||
enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
def fail_refresh(*_args, **_kwargs):
|
||||
raise AssertionError("default cache warmer must not directly refresh city panel")
|
||||
|
||||
monkeypatch.setattr(cache_warmer_service, "_CACHE_WARMER_DB", FakeDB(), raising=False)
|
||||
monkeypatch.setattr(city_runtime, "_refresh_city_panel_cache", fail_refresh)
|
||||
|
||||
warmer = build_default_cache_warmer()
|
||||
|
||||
assert warmer.city_panel_warmer("shenzhen", force_refresh=False) is True
|
||||
assert enqueued == [
|
||||
{
|
||||
"city": "shenzhen",
|
||||
"kind": "panel",
|
||||
"priority": "normal",
|
||||
"reason": "cache_warmer",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_cache_warmer_skips_work_when_intervals_are_not_due():
|
||||
now_ts = datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc).timestamp()
|
||||
scan_calls = []
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import pytest
|
||||
|
||||
|
||||
def test_daily_weather_report_reads_city_cache_without_external_fetch(monkeypatch):
|
||||
from src.utils import daily_weather_report
|
||||
|
||||
class FailCollector:
|
||||
def fetch_all_sources(self, *_args, **_kwargs):
|
||||
raise AssertionError("daily report must not fetch external weather sources")
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self.enqueued = []
|
||||
|
||||
def get_city_cache(self, kind, city):
|
||||
if (kind, city) != ("panel", "beijing"):
|
||||
return None
|
||||
return {
|
||||
"payload": {
|
||||
"display_name": "Beijing",
|
||||
"local_date": "2026-06-14",
|
||||
"current": {"temp": 27.0, "max_so_far": 28.0},
|
||||
"deb": {"prediction": 31.5},
|
||||
}
|
||||
}
|
||||
|
||||
def enqueue_observation_refresh_request(self, **kwargs):
|
||||
self.enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
fake_db = FakeDB()
|
||||
monkeypatch.setattr(daily_weather_report, "_DAILY_REPORT_DB", fake_db, raising=False)
|
||||
monkeypatch.setattr(
|
||||
daily_weather_report.httpx,
|
||||
"get",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("daily report must not scrape CMA from business path")
|
||||
),
|
||||
)
|
||||
|
||||
data = daily_weather_report._fetch_city_data(FailCollector(), "beijing")
|
||||
|
||||
assert data == {
|
||||
"city": "beijing",
|
||||
"name": "北京",
|
||||
"name_en": "Beijing",
|
||||
"weather": "?",
|
||||
"forecast_high": 31.5,
|
||||
}
|
||||
assert fake_db.enqueued == []
|
||||
|
||||
|
||||
def test_daily_weather_report_cold_cache_enqueues_refresh_without_fetch(monkeypatch):
|
||||
from src.utils import daily_weather_report
|
||||
|
||||
class FailCollector:
|
||||
def fetch_all_sources(self, *_args, **_kwargs):
|
||||
raise AssertionError("daily report must not fetch external weather sources")
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self.enqueued = []
|
||||
|
||||
def get_city_cache(self, _kind, _city):
|
||||
return None
|
||||
|
||||
def get_canonical_temperature(self, _city):
|
||||
return None
|
||||
|
||||
def enqueue_observation_refresh_request(self, **kwargs):
|
||||
self.enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
fake_db = FakeDB()
|
||||
monkeypatch.setattr(daily_weather_report, "_DAILY_REPORT_DB", fake_db, raising=False)
|
||||
monkeypatch.setattr(
|
||||
daily_weather_report.httpx,
|
||||
"get",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("daily report must not scrape CMA from business path")
|
||||
),
|
||||
)
|
||||
|
||||
assert daily_weather_report._fetch_city_data(FailCollector(), "beijing") is None
|
||||
assert fake_db.enqueued == [
|
||||
{
|
||||
"city": "beijing",
|
||||
"kind": "panel",
|
||||
"priority": "normal",
|
||||
"reason": "daily_weather_report",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_city_analysis_service_builds_report_from_cached_payload_without_fetch():
|
||||
from src.bot.analysis.city_analysis_service import CityAnalysisService
|
||||
|
||||
class FailWeather:
|
||||
def get_coordinates(self, city_name):
|
||||
assert city_name == "shanghai"
|
||||
return {"lat": 31.1434, "lon": 121.8052}
|
||||
|
||||
def fetch_all_sources(self, *_args, **_kwargs):
|
||||
raise AssertionError("city report must not fetch external weather sources")
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self.enqueued = []
|
||||
|
||||
def get_city_cache(self, kind, city):
|
||||
if (kind, city) != ("panel", "shanghai"):
|
||||
return None
|
||||
return {
|
||||
"payload": {
|
||||
"local_date": "2026-06-14",
|
||||
"current": {
|
||||
"temp": 29.2,
|
||||
"max_so_far": 30.1,
|
||||
"observed_at": "2026-06-14T05:00:00+00:00",
|
||||
},
|
||||
"airport_current": {
|
||||
"temp": 29.2,
|
||||
"max_so_far": 30.1,
|
||||
"observation_time": "2026-06-14T05:00:00+00:00",
|
||||
},
|
||||
"deb": {"prediction": 32.4},
|
||||
}
|
||||
}
|
||||
|
||||
def enqueue_observation_refresh_request(self, **kwargs):
|
||||
self.enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
service = CityAnalysisService(weather=FailWeather(), cache_db=FakeDB())
|
||||
|
||||
report = service.build_city_report("shanghai", 3)
|
||||
|
||||
assert "Shanghai" in report
|
||||
assert "32.4°C" in report
|
||||
assert "29.2°C" in report
|
||||
assert "本次消耗 <b>3</b> 积分" in report
|
||||
|
||||
|
||||
def test_city_analysis_service_cold_cache_enqueues_and_asks_to_retry():
|
||||
from src.bot.analysis.city_analysis_service import CityAnalysisService
|
||||
|
||||
class FailWeather:
|
||||
def get_coordinates(self, _city_name):
|
||||
return {"lat": 31.1434, "lon": 121.8052}
|
||||
|
||||
def fetch_all_sources(self, *_args, **_kwargs):
|
||||
raise AssertionError("city report must not fetch external weather sources")
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self.enqueued = []
|
||||
|
||||
def get_city_cache(self, _kind, _city):
|
||||
return None
|
||||
|
||||
def get_canonical_temperature(self, _city):
|
||||
return None
|
||||
|
||||
def enqueue_observation_refresh_request(self, **kwargs):
|
||||
self.enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
fake_db = FakeDB()
|
||||
service = CityAnalysisService(weather=FailWeather(), cache_db=fake_db)
|
||||
|
||||
with pytest.raises(RuntimeError, match="缓存正在初始化"):
|
||||
service.build_city_report("shanghai", 3)
|
||||
|
||||
assert fake_db.enqueued == [
|
||||
{
|
||||
"city": "shanghai",
|
||||
"kind": "panel",
|
||||
"priority": "high",
|
||||
"reason": "bot_city_report",
|
||||
}
|
||||
]
|
||||
@@ -89,7 +89,7 @@ def test_refresh_city_panel_cache_persists_canonical_temperature(monkeypatch):
|
||||
def test_city_panel_cold_cache_returns_canonical_latest_without_sync_refresh(monkeypatch):
|
||||
import web.services.city_api as city_api
|
||||
|
||||
started = []
|
||||
enqueued = []
|
||||
|
||||
class FakeDB:
|
||||
def get_city_cache(self, kind, city):
|
||||
@@ -113,8 +113,12 @@ def test_city_panel_cold_cache_returns_canonical_latest_without_sync_refresh(mon
|
||||
"freshness_status": "fresh",
|
||||
"fetched_at": "2026-06-14T01:02:03+00:00",
|
||||
"confidence": 0.92,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def enqueue_observation_refresh_request(self, **kwargs):
|
||||
enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
def fail_refresh(*_args, **_kwargs):
|
||||
raise AssertionError("cold panel cache must not synchronously refresh when canonical latest exists")
|
||||
@@ -122,11 +126,6 @@ def test_city_panel_cold_cache_returns_canonical_latest_without_sync_refresh(mon
|
||||
monkeypatch.setattr(city_api.legacy_routes, "_normalize_city_or_404", lambda name: name.strip().lower())
|
||||
monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeDB())
|
||||
monkeypatch.setattr(city_api.legacy_routes, "_refresh_city_panel_cache", fail_refresh)
|
||||
monkeypatch.setattr(
|
||||
city_api,
|
||||
"_start_city_cache_stale_refresh",
|
||||
lambda city, kind, refresh_fn: started.append((city, kind, refresh_fn)),
|
||||
)
|
||||
|
||||
payload = asyncio.run(
|
||||
city_api.get_city_detail_payload(
|
||||
@@ -140,13 +139,20 @@ def test_city_panel_cold_cache_returns_canonical_latest_without_sync_refresh(mon
|
||||
assert payload["current"]["temp"] == 31.2
|
||||
assert payload["canonical_temperature"]["source"] == "amsc_awos"
|
||||
assert payload["detail_depth"] == "panel"
|
||||
assert [(city, kind) for city, kind, _ in started] == [("shanghai", "panel")]
|
||||
assert enqueued == [
|
||||
{
|
||||
"city": "shanghai",
|
||||
"kind": "panel",
|
||||
"priority": "high",
|
||||
"reason": "canonical_fallback",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_city_full_cold_cache_returns_canonical_latest_without_sync_refresh(monkeypatch):
|
||||
import web.services.city_api as city_api
|
||||
|
||||
started = []
|
||||
enqueued = []
|
||||
|
||||
class FakeDB:
|
||||
def get_city_cache(self, kind, city):
|
||||
@@ -170,28 +176,38 @@ def test_city_full_cold_cache_returns_canonical_latest_without_sync_refresh(monk
|
||||
"freshness_status": "fresh",
|
||||
"fetched_at": "2026-06-14T01:02:03+00:00",
|
||||
"confidence": 0.92,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def enqueue_observation_refresh_request(self, **kwargs):
|
||||
enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
def fail_refresh(*_args, **_kwargs):
|
||||
raise AssertionError("cold full cache must not synchronously refresh when canonical latest exists")
|
||||
|
||||
monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeDB())
|
||||
monkeypatch.setattr(city_api.legacy_routes, "_refresh_city_full_cache", fail_refresh)
|
||||
monkeypatch.setattr(city_api, "_start_city_full_stale_refresh", lambda city: started.append(city))
|
||||
|
||||
payload = asyncio.run(city_api._get_city_full_data("shanghai", force_refresh=False))
|
||||
|
||||
assert payload["current"]["temp"] == 31.2
|
||||
assert payload["canonical_temperature"]["source"] == "amsc_awos"
|
||||
assert payload["detail_depth"] == "full"
|
||||
assert started == ["shanghai"]
|
||||
assert enqueued == [
|
||||
{
|
||||
"city": "shanghai",
|
||||
"kind": "full",
|
||||
"priority": "high",
|
||||
"reason": "canonical_fallback",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_city_nearby_and_market_cold_cache_return_canonical_latest_without_sync_refresh(monkeypatch):
|
||||
import web.services.city_api as city_api
|
||||
|
||||
started = []
|
||||
enqueued = []
|
||||
requested_kinds = []
|
||||
|
||||
class FakeDB:
|
||||
@@ -215,8 +231,12 @@ def test_city_nearby_and_market_cold_cache_return_canonical_latest_without_sync_
|
||||
"freshness_status": "fresh",
|
||||
"fetched_at": "2026-06-14T01:02:03+00:00",
|
||||
"confidence": 0.92,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def enqueue_observation_refresh_request(self, **kwargs):
|
||||
enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
def fail_refresh(*_args, **_kwargs):
|
||||
raise AssertionError("cold city cache must not synchronously refresh when canonical latest exists")
|
||||
@@ -226,11 +246,6 @@ def test_city_nearby_and_market_cold_cache_return_canonical_latest_without_sync_
|
||||
monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeDB())
|
||||
monkeypatch.setattr(city_api.legacy_routes, "_refresh_city_nearby_cache", fail_refresh)
|
||||
monkeypatch.setattr(city_api.legacy_routes, "_refresh_city_market_cache", fail_refresh)
|
||||
monkeypatch.setattr(
|
||||
city_api,
|
||||
"_start_city_cache_stale_refresh",
|
||||
lambda city, kind, refresh_fn: started.append((city, kind, refresh_fn)),
|
||||
)
|
||||
|
||||
nearby = asyncio.run(
|
||||
city_api.get_city_detail_payload(object(), "Shanghai", force_refresh=False, depth="nearby")
|
||||
@@ -243,9 +258,19 @@ def test_city_nearby_and_market_cold_cache_return_canonical_latest_without_sync_
|
||||
assert market["detail_depth"] == "market"
|
||||
assert nearby["current"]["temp"] == 31.2
|
||||
assert market["current"]["temp"] == 31.2
|
||||
assert [(city, kind) for city, kind, _ in started] == [
|
||||
("shanghai", "nearby"),
|
||||
("shanghai", "market"),
|
||||
assert enqueued == [
|
||||
{
|
||||
"city": "shanghai",
|
||||
"kind": "nearby",
|
||||
"priority": "high",
|
||||
"reason": "canonical_fallback",
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"kind": "market",
|
||||
"priority": "high",
|
||||
"reason": "canonical_fallback",
|
||||
},
|
||||
]
|
||||
assert requested_kinds == [("nearby", "shanghai"), ("market", "shanghai")]
|
||||
|
||||
|
||||
@@ -1716,13 +1716,14 @@ def test_stale_city_detail_uses_cached_full_payload_while_refreshing(monkeypatch
|
||||
|
||||
assert result["live_temp"] == 20.0
|
||||
assert build_inputs == [20.0]
|
||||
assert refresh_calls == 1
|
||||
assert refresh_calls == 0
|
||||
|
||||
|
||||
def test_force_refresh_panel_returns_cached_payload_when_refresh_is_slow(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
refresh_calls = 0
|
||||
enqueued = []
|
||||
|
||||
class FakeCache:
|
||||
def get_city_cache(self, kind, city):
|
||||
@@ -1736,6 +1737,10 @@ def test_force_refresh_panel_returns_cached_payload_when_refresh_is_slow(monkeyp
|
||||
},
|
||||
}
|
||||
|
||||
def enqueue_observation_refresh_request(self, **kwargs):
|
||||
enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
async def fake_run_in_threadpool(fn, *args, **kwargs):
|
||||
if fn is city_api.legacy_routes._refresh_city_panel_cache:
|
||||
await asyncio.sleep(0.05)
|
||||
@@ -1767,13 +1772,22 @@ def test_force_refresh_panel_returns_cached_payload_when_refresh_is_slow(monkeyp
|
||||
|
||||
assert result["from_cache"] is True
|
||||
assert result["deb"]["prediction"] == 20.0
|
||||
assert refresh_calls == 1
|
||||
assert refresh_calls == 0
|
||||
assert enqueued == [
|
||||
{
|
||||
"city": "paris",
|
||||
"kind": "panel",
|
||||
"priority": "high",
|
||||
"reason": "force_refresh",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_force_refresh_panel_returns_cached_payload_when_refresh_already_running(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
refresh_calls = 0
|
||||
enqueued = []
|
||||
|
||||
class FakeCache:
|
||||
def get_city_cache(self, kind, city):
|
||||
@@ -1787,6 +1801,10 @@ def test_force_refresh_panel_returns_cached_payload_when_refresh_already_running
|
||||
},
|
||||
}
|
||||
|
||||
def enqueue_observation_refresh_request(self, **kwargs):
|
||||
enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
async def fake_run_in_threadpool(fn, *args, **kwargs):
|
||||
if fn is city_api.legacy_routes._refresh_city_panel_cache:
|
||||
await asyncio.sleep(0.08)
|
||||
@@ -1827,16 +1845,31 @@ def test_force_refresh_panel_returns_cached_payload_when_refresh_already_running
|
||||
|
||||
first_result, second_result = asyncio.run(run_requests())
|
||||
|
||||
assert first_result["from_cache"] is False
|
||||
assert first_result["from_cache"] is True
|
||||
assert second_result["from_cache"] is True
|
||||
assert second_result["deb"]["prediction"] == 20.0
|
||||
assert refresh_calls == 1
|
||||
assert refresh_calls == 0
|
||||
assert enqueued == [
|
||||
{
|
||||
"city": "paris",
|
||||
"kind": "panel",
|
||||
"priority": "high",
|
||||
"reason": "force_refresh",
|
||||
},
|
||||
{
|
||||
"city": "paris",
|
||||
"kind": "panel",
|
||||
"priority": "high",
|
||||
"reason": "force_refresh",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_stale_panel_returns_cached_payload_while_refreshing(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
refresh_calls = 0
|
||||
enqueued = []
|
||||
|
||||
class FakeCache:
|
||||
def get_city_cache(self, kind, city):
|
||||
@@ -1850,6 +1883,10 @@ def test_stale_panel_returns_cached_payload_while_refreshing(monkeypatch):
|
||||
},
|
||||
}
|
||||
|
||||
def enqueue_observation_refresh_request(self, **kwargs):
|
||||
enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
async def fake_run_in_threadpool(fn, *args, **kwargs):
|
||||
if fn is city_api.legacy_routes._refresh_city_panel_cache:
|
||||
await asyncio.sleep(0.05)
|
||||
@@ -1883,7 +1920,15 @@ def test_stale_panel_returns_cached_payload_while_refreshing(monkeypatch):
|
||||
|
||||
assert result["from_cache"] is True
|
||||
assert result["deb"]["prediction"] == 20.0
|
||||
assert refresh_calls == 1
|
||||
assert refresh_calls == 0
|
||||
assert enqueued == [
|
||||
{
|
||||
"city": "paris",
|
||||
"kind": "panel",
|
||||
"priority": "high",
|
||||
"reason": "stale_refresh",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_force_refresh_full_detail_returns_cached_payload_when_refresh_is_slow(monkeypatch):
|
||||
@@ -1891,6 +1936,7 @@ def test_force_refresh_full_detail_returns_cached_payload_when_refresh_is_slow(m
|
||||
|
||||
refresh_calls = 0
|
||||
build_inputs = []
|
||||
enqueued = []
|
||||
|
||||
class FakeCache:
|
||||
def get_city_cache(self, kind, city):
|
||||
@@ -1903,6 +1949,10 @@ def test_force_refresh_full_detail_returns_cached_payload_when_refresh_is_slow(m
|
||||
},
|
||||
}
|
||||
|
||||
def enqueue_observation_refresh_request(self, **kwargs):
|
||||
enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
async def fake_run_in_threadpool(fn, *args, **kwargs):
|
||||
if fn is city_api.legacy_routes._refresh_city_full_cache:
|
||||
await asyncio.sleep(0.05)
|
||||
@@ -1948,7 +1998,15 @@ def test_force_refresh_full_detail_returns_cached_payload_when_refresh_is_slow(m
|
||||
|
||||
assert result["live_temp"] == 20.0
|
||||
assert build_inputs == [20.0]
|
||||
assert refresh_calls == 1
|
||||
assert refresh_calls == 0
|
||||
assert enqueued == [
|
||||
{
|
||||
"city": "paris",
|
||||
"kind": "full",
|
||||
"priority": "high",
|
||||
"reason": "force_refresh",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_force_refresh_cold_city_detail_returns_initializing_without_full_refresh(monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user