Queue stale weather refreshes from user paths

This commit is contained in:
2569718930@qq.com
2026-06-14 18:21:53 +08:00
parent e111031119
commit 3e1e06d8e1
8 changed files with 632 additions and 265 deletions
+32 -1
View File
@@ -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 = []
+181
View File
@@ -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",
}
]
+47 -22
View File
@@ -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")]
+64 -6
View File
@@ -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):