Files
PolyWeather/tests/test_web_observability.py
T

806 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from fastapi.testclient import TestClient
from web.app import app
import web.routes as routes
import web.scan_terminal_cache as scan_terminal_cache
import web.scan_terminal_service as scan_terminal_service
from web.scan_terminal_cache import scan_terminal_cache_key
from src.database.runtime_state import TruthRecordRepository
client = TestClient(app)
def test_healthz_returns_ok_shape():
response = client.get('/healthz')
assert response.status_code == 200
payload = response.json()
assert payload['status'] in {'ok', 'degraded'}
assert 'db' in payload
assert 'state_storage_mode' in payload
assert 'cities_count' in payload
def test_system_status_returns_summary_shape():
response = client.get('/api/system/status')
assert response.status_code == 200
payload = response.json()
assert 'db' in payload
assert 'state_storage_mode' in payload
assert 'features' in payload
assert 'integrations' in payload
assert 'cache' in payload
assert 'analysis' in payload['cache']
assert 'probability' in payload
assert payload['probability']['engine_mode'] == 'legacy'
assert 'training_data' in payload
assert 'station_networks' in payload
assert 'truth_records' in payload['training_data']
assert 'training_features' in payload['training_data']
assert 'city_coverage' in payload['training_data']
assert 'model_city_coverage' in payload['training_data']
assert 'metar_entries' in payload['cache']
assert 'cities_count' in payload
def test_metrics_endpoint_returns_prometheus_payload():
response = client.get('/metrics')
assert response.status_code == 200
assert 'polyweather_http_requests_total' in response.text
def test_city_ai_fallback_reasoning_identifies_fast_evidence_mode():
payload = scan_terminal_service._build_city_ai_fallback(
{
"city_display_name": "Tokyo",
"temp_symbol": "°C",
"deb": {"prediction": 17.8},
"model_cluster": {
"sources": [
{"value": 17.0},
{"value": 17.8},
{"value": 20.6},
]
},
"observation_anchor": {
"is_airport_metar": True,
"station_code": "RJTT",
},
"airport_current": {
"station_code": "RJTT",
"temp": 16.0,
"report_time": "21:30Z",
"raw_metar": "RJTT 262130Z AUTO 00000KT 9999 FEW030 16/10 Q1015",
},
},
locale="zh-CN",
reason="preview",
)
assert "当前为快速证据模式" in payload["reasoning_zh"]
assert "完整 AI 机场报文解读返回后再合并" in payload["reasoning_zh"]
assert "AI 机场报文解读正常" not in payload["reasoning_zh"]
assert "后补" not in payload["reasoning_zh"]
assert "AI 增强可作为后续补充" not in payload["reasoning_zh"]
def test_city_ai_fallback_revises_up_when_latest_metar_breaks_above_models():
payload = scan_terminal_service._build_city_ai_fallback(
{
"city_display_name": "Manila",
"temp_symbol": "°C",
"deb": {"prediction": 34.0},
"model_cluster": {
"sources": [
{"value": 32.5},
{"value": 33.8},
{"value": 34.0},
{"value": 34.7},
]
},
"observation_anchor": {
"is_airport_metar": True,
"station_code": "RPLL",
},
"airport_current": {
"station_code": "RPLL",
"temp": 35.0,
"report_time": "03:00Z / 当地 11:00",
"raw_metar": "RPLL 270300Z 34004KT CAVOK 35/24 Q1009",
},
},
locale="zh-CN",
reason="stream preview",
)
assert payload["predicted_max"] == 35.0
assert payload["range_high"] == 35.0
assert "高于原先 34.0°C 中枢" in payload["final_judgment_zh"]
assert "上修到至少 35.0°C" in payload["final_judgment_zh"]
assert "共同支撑本轮最高温中枢" not in payload["reasoning_zh"]
assert "超过模型上沿 34.7°C" in payload["reasoning_zh"]
assert "继续上修最高温中枢" in payload["risks_zh"][0]
def test_city_ai_fallback_revises_down_after_peak_when_observed_high_lags():
payload = scan_terminal_service._build_city_ai_fallback(
{
"city_display_name": "London",
"temp_symbol": "°C",
"deb": {"prediction": 30.0},
"model_cluster": {
"sources": [
{"value": 29.2},
{"value": 30.0},
{"value": 31.1},
]
},
"window_phase": "post_peak",
"peak_window_label": "14:00-16:59",
"observation_anchor": {
"is_airport_metar": True,
"station_code": "EGLL",
},
"airport_current": {
"station_code": "EGLL",
"temp": 27.0,
"max_so_far": 27.5,
"report_time": "16:30Z",
"raw_metar": "EGLL 271630Z 22008KT 9999 SCT030 27/15 Q1012",
},
},
locale="zh-CN",
reason="stream preview",
)
assert payload["predicted_max"] == 27.5
assert "峰值窗口(14:00-16:59)已过或接近结束" in payload["final_judgment_zh"]
assert "最高温中枢需先下修到 27.5°C" in payload["final_judgment_zh"]
assert "共同支撑本轮最高温中枢" not in payload["reasoning_zh"]
assert "下修压力" in payload["reasoning_zh"]
assert "继续下修最高温中枢" in payload["risks_zh"][0]
def test_city_ai_fallback_does_not_downrevise_before_peak_window():
payload = scan_terminal_service._build_city_ai_fallback(
{
"city_display_name": "Dubai",
"temp_symbol": "°C",
"deb": {"prediction": 41.0},
"model_cluster": {
"sources": [
{"value": 40.5},
{"value": 41.0},
{"value": 41.6},
]
},
"window_phase": "early_today",
"minutes_until_peak_start": 240,
"peak_window_label": "14:00-16:59",
"observation_anchor": {
"is_airport_metar": True,
"station_code": "OMDB",
},
"airport_current": {
"station_code": "OMDB",
"temp": 35.0,
"report_time": "08:00Z",
"raw_metar": "OMDB 270800Z 29007KT CAVOK 35/20 Q1008",
},
},
locale="zh-CN",
reason="stream preview",
)
assert payload["predicted_max"] == 41.0
assert "暂不直接下修" in payload["reasoning_zh"]
assert "峰值窗口尚未到来" in payload["reasoning_zh"]
assert "若峰值窗口前继续偏低,需要下修最高温中枢" in payload["risks_zh"][0]
def test_city_ai_fallback_marks_peak_window_passed_without_waiting_for_warming():
payload = scan_terminal_service._build_city_ai_fallback(
{
"city_display_name": "Paris",
"temp_symbol": "°C",
"deb": {"prediction": 28.0},
"model_cluster": {
"sources": [
{"value": 27.6},
{"value": 28.0},
{"value": 28.5},
]
},
"window_phase": "post_peak",
"peak_window_label": "13:00-15:59",
"observation_anchor": {
"is_airport_metar": True,
"station_code": "LFPG",
},
"airport_current": {
"station_code": "LFPG",
"temp": 27.0,
"max_so_far": 27.2,
"report_time": "17:00Z",
"raw_metar": "LFPG 271700Z 25006KT 9999 FEW035 27/13 Q1014",
},
},
locale="zh-CN",
reason="stream preview",
)
assert "峰值窗口(13:00-15:59)已过" in payload["final_judgment_zh"]
assert "不是继续按待升温路径解读" in payload["reasoning_zh"]
assert "避免继续上调最高温中枢" in payload["risks_zh"][0]
def test_city_ai_fallback_treats_stale_metar_as_background_not_anchor():
payload = scan_terminal_service._build_city_ai_fallback(
{
"city_display_name": "Manila",
"temp_symbol": "°C",
"deb": {"prediction": 34.0},
"model_cluster": {
"sources": [
{"value": 33.5},
{"value": 34.0},
{"value": 34.4},
]
},
"metar_context": {
"stale_for_today": True,
"last_observation_time": "00:00Z",
},
"observation_anchor": {
"is_airport_metar": True,
"station_code": "RPLL",
},
"airport_current": {
"station_code": "RPLL",
"temp": 36.0,
"report_time": "00:00Z",
"raw_metar": "RPLL 270000Z 34004KT CAVOK 36/24 Q1009",
},
},
locale="zh-CN",
reason="stream preview",
)
assert payload["predicted_max"] == 34.0
assert "过旧" in payload["metar_read_zh"]
assert "不能作为强实况锚点" in payload["metar_read_zh"]
assert "先以 DEB 和多模型路径为主" in payload["final_judgment_zh"]
assert "不能作为强实况锚点" in payload["reasoning_zh"]
assert "上修到至少 36.0°C" not in payload["final_judgment_zh"]
def test_city_ai_cache_key_changes_when_observation_fingerprint_changes():
"""METAR 原文不变 → 缓存 key 不变(命中);METAR 原文变了 → key 变化(miss)。"""
base_input = {
"city": "Manila",
"local_date": "2026-04-28",
"observation_anchor": {
"source": "METAR",
"is_airport_metar": True,
"station_code": "RPLL",
},
"airport_current": {
"obs_time": "03:00Z",
"raw_metar": "RPLL 280300Z 34004KT CAVOK 34/24 Q1009",
},
"metar_context": {
"stale_for_today": False,
"last_observation_time": "03:00Z",
},
}
changed_input = {
**base_input,
"airport_current": {
**base_input["airport_current"],
"raw_metar": "RPLL 280330Z 36006KT 9999 FEW020 33/25 Q1010",
"obs_time": "03:30Z",
},
}
assert scan_terminal_service._scan_city_ai_cache_key(base_input) != scan_terminal_service._scan_city_ai_cache_key(changed_input)
def test_city_ai_stream_request_only_asks_provider_for_observation_read():
request_payload = scan_terminal_service._build_city_ai_stream_request(
{
"city": "Tokyo",
"city_display_name": "Tokyo",
"temp_symbol": "°C",
"deb": {"prediction": 17.8},
"model_cluster": {"sources": [{"value": 17.0}, {"value": 17.8}]},
"observation_anchor": {
"is_airport_metar": True,
"read_label_zh": "机场报文解读",
},
"airport_current": {
"station_code": "RJTT",
"temp": 16.0,
"report_time": "21:30Z",
"raw_metar": "RJTT 262130Z AUTO 00000KT 9999 FEW030 16/10 Q1015",
},
},
locale="zh-CN",
)
user_payload = request_payload["messages"][1]["content"]
assert request_payload["stream"] is True
assert request_payload["max_tokens"] <= 1200
assert request_payload["max_tokens"] < scan_terminal_service.SCAN_AI_MAX_TOKENS
assert "taf_read_zh" in user_payload
assert "probability_read_zh" in user_payload
assert "predicted_max" in user_payload
assert "final_judgment" in user_payload
def test_city_ai_partial_json_trims_dangling_taf_clause():
payload = scan_terminal_service._build_city_ai_fallback(
{
"city_display_name": "London",
"temp_symbol": "°C",
"deb": {"prediction": 24.3},
"model_cluster": {
"sources": [
{"value": 22.3},
{"value": 23.1},
{"value": 24.3},
{"value": 26.3},
]
},
"observation_anchor": {
"is_airport_metar": True,
"station_code": "EGLL",
},
"airport_current": {
"station_code": "EGLL",
"temp": 21.0,
"report_time": "09:00Z",
"raw_metar": "EGLL 270900Z 34004KT CAVOK 21/09 Q1016",
},
},
locale="zh-CN",
reason="AI content is not a JSON object",
raw_content=(
'{"metar_read_zh":"最新METAR报文09:00观测温度21°C,西北风4节(340°),'
'CAVOK(能见度良好,无重要云)。当前西北风弱,趋向增温但影响有限;'
'TAF预示10-11点转南风(18012KT),南风可能带来凉爽海风抑制升温。",'
'"reasoning_zh":"DEB预测24.3°C,多数模型集中在23-26°C'
'当前09时实测21°C处于快速升温路径,但TAF显示'
),
)
assert "但TAF显示" not in payload["reasoning_zh"]
assert payload["reasoning_zh"].endswith("。")
assert "当前09时实测21°C处于快速升温路径" in payload["reasoning_zh"]
def test_city_ai_schema_completion_trims_dangling_taf_clause():
payload = scan_terminal_service._complete_city_ai_payload(
{
"predicted_max": 24.3,
"range_low": 22.3,
"range_high": 26.3,
"unit": "°C",
"confidence": "medium",
"final_judgment_zh": "London 最高温中枢暂看24°C附近。",
"final_judgment_en": "London high is centered near 24°C.",
"metar_read_zh": "最新METAR报文09:00观测温度21°C,西北风4节,CAVOK。",
"metar_read_en": "The latest METAR shows 21°C at 09:00 with northwesterly wind and CAVOK.",
"reasoning_zh": "当前09时实测21°C处于快速升温路径,但TAF显示",
"reasoning_en": "The 09:00 observation is on a fast warming path, but TAF shows",
"risks_zh": ["后续METAR若升温放缓,需要下修。"],
"risks_en": ["If later METAR warming slows, revise lower."],
"model_cluster_note_zh": "4/4 个模型落在 DEB ±2°C 内。",
"model_cluster_note_en": "4/4 models sit within 2°C of DEB.",
},
{
"city_display_name": "London",
"temp_symbol": "°C",
"deb": {"prediction": 24.3},
"model_cluster": {"sources": [{"value": 22.3}, {"value": 26.3}]},
"observation_anchor": {"is_airport_metar": True, "station_code": "EGLL"},
"airport_current": {
"station_code": "EGLL",
"temp": 21.0,
"report_time": "09:00Z",
"raw_metar": "EGLL 270900Z 34004KT CAVOK 21/09 Q1016",
},
},
locale="zh-CN",
)
assert payload["reasoning_zh"] == "当前09时实测21°C处于快速升温路径。"
assert payload["reasoning_en"] == "The 09:00 observation is on a fast warming path."
assert payload["_polyweather_meta"]["trimmed_incomplete_fields"] == [
"reasoning_en",
"reasoning_zh",
]
def test_city_ai_schema_completion_guards_stale_observation_text():
payload = scan_terminal_service._complete_city_ai_payload(
{
"predicted_max": 36.0,
"range_low": 35.0,
"range_high": 37.0,
"unit": "°C",
"confidence": "medium",
"final_judgment_zh": "Manila 最新 METAR 已经支撑 36°C 高温中枢。",
"final_judgment_en": "Manila latest METAR supports a 36°C high center.",
"metar_read_zh": "RPLL 最新 METAR 显示 36°C,当前作为强实况锚点。",
"metar_read_en": "RPLL latest METAR shows 36°C and is a strong live anchor.",
"reasoning_zh": "最新 METAR 与模型共同支撑上修。",
"reasoning_en": "Latest METAR and models jointly support an upward revision.",
"risks_zh": ["若继续升温,需要上修。"],
"risks_en": ["If it keeps warming, revise upward."],
"model_cluster_note_zh": "3/3 个模型集中。",
"model_cluster_note_en": "3/3 models are clustered.",
},
{
"city_display_name": "Manila",
"temp_symbol": "°C",
"deb": {"prediction": 34.0},
"model_cluster": {
"sources": [{"value": 33.5}, {"value": 34.0}, {"value": 34.4}]
},
"metar_context": {
"stale_for_today": True,
"last_observation_time": "00:00Z",
},
"observation_anchor": {"is_airport_metar": True, "station_code": "RPLL"},
"airport_current": {
"station_code": "RPLL",
"temp": 36.0,
"report_time": "00:00Z",
"raw_metar": "RPLL 270000Z 34004KT CAVOK 36/24 Q1009",
},
},
locale="zh-CN",
)
assert payload["predicted_max"] == 34.0
assert "过旧" in payload["metar_read_zh"]
assert "不能作为强实况锚点" in payload["metar_read_zh"]
assert "先以 DEB 和多模型路径为主" in payload["final_judgment_zh"]
assert "共同支撑上修" not in payload["reasoning_zh"]
assert "deterministic_guard_fields" in payload["_polyweather_meta"]
def test_city_ai_schema_completion_guards_observed_high_break_numbers():
payload = scan_terminal_service._complete_city_ai_payload(
{
"predicted_max": 34.0,
"range_low": 32.5,
"range_high": 34.7,
"unit": "°C",
"confidence": "medium",
"final_judgment_zh": "Manila 最高温仍以 34.0°C 为中枢。",
"final_judgment_en": "Manila high remains centered near 34.0°C.",
"metar_read_zh": "RPLL 最新 METAR 显示 35°CCAVOK。",
"metar_read_en": "RPLL latest METAR shows 35°C and CAVOK.",
"reasoning_zh": "模型区间仍覆盖当前路径,无需上修。",
"reasoning_en": "The model range still covers the path, so no upward revision is needed.",
"risks_zh": ["后续报文偏离再修正。"],
"risks_en": ["Revise if later reports diverge."],
"model_cluster_note_zh": "4/4 个模型集中。",
"model_cluster_note_en": "4/4 models are clustered.",
},
{
"city_display_name": "Manila",
"temp_symbol": "°C",
"deb": {"prediction": 34.0},
"model_cluster": {
"sources": [
{"value": 32.5},
{"value": 33.8},
{"value": 34.0},
{"value": 34.7},
]
},
"observation_anchor": {"is_airport_metar": True, "station_code": "RPLL"},
"airport_current": {
"station_code": "RPLL",
"temp": 35.0,
"report_time": "03:00Z / 当地 11:00",
"raw_metar": "RPLL 270300Z 34004KT CAVOK 35/24 Q1009",
},
},
locale="zh-CN",
)
assert payload["predicted_max"] == 35.0
assert payload["range_high"] == 35.0
assert "上修到至少 35.0°C" in payload["final_judgment_zh"]
assert "无需上修" not in payload["reasoning_zh"]
assert "deterministic_guard_fields" in payload["_polyweather_meta"]
def test_cities_endpoint_uses_denver_display_name_for_aurora_market():
response = client.get("/api/cities")
assert response.status_code == 200
payload = response.json()
denver = next(item for item in payload["cities"] if item["name"] == "denver")
assert denver["display_name"] == "Denver"
assert denver["network_provider"] == "global_metar"
assert denver["deb_recent_tier"] in {"high", "medium", "low", "other"}
assert "deb_recent_sample_count" in denver
def test_cities_endpoint_includes_new_wunderground_cities():
response = client.get("/api/cities")
assert response.status_code == 200
payload = response.json()
names = {item["name"] for item in payload["cities"]}
assert {
"busan",
"qingdao",
"panama city",
"kuala lumpur",
"jakarta",
"helsinki",
"amsterdam",
}.issubset(names)
def test_payment_runtime_endpoint_returns_shape():
response = client.get('/api/payments/runtime')
assert response.status_code == 200
payload = response.json()
assert 'checkout' in payload
assert 'rpc' in payload
assert 'event_loop_state' in payload
assert 'recent_audit_events' in payload
def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
def _bind_identity(request):
request.state.auth_user_id = "user-1"
request.state.auth_email = "user@example.com"
monkeypatch.setattr(routes, "_bind_optional_supabase_identity", _bind_identity)
monkeypatch.setattr(routes, "_resolve_auth_points", lambda request: 0)
monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None})
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "enabled", True)
calls = {"count": 0}
reconcile_calls = {"count": 0}
def _latest_subscription(user_id, respect_requirement=False):
calls["count"] += 1
return {
"plan_code": "pro_monthly",
"starts_at": "2026-03-22T00:00:00+00:00",
"expires_at": "2026-04-21T00:00:00+00:00",
}
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"get_latest_active_subscription",
_latest_subscription,
)
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", True)
def _reconcile_latest_intent(user_id):
reconcile_calls["count"] += 1
return {"ok": True, "action": "reconciled_confirmed_intent"}
monkeypatch.setattr(
routes.PAYMENT_CHECKOUT,
"reconcile_latest_intent",
_reconcile_latest_intent,
)
response = client.get("/api/auth/me")
assert response.status_code == 200
payload = response.json()
assert payload["subscription_active"] is True
assert payload["subscription_plan_code"] == "pro_monthly"
assert reconcile_calls["count"] == 0
def test_ops_memberships_prefers_supabase_auth_email(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", False)
class _FakeDB:
@staticmethod
def get_users_by_supabase_user_ids(user_ids):
return {
"user-1": {
"supabase_email": "stale@example.com",
"username": "tester",
"telegram_id": 1,
"created_at": "2026-03-01T00:00:00+00:00",
}
}
import src.database.db_manager as db_module
monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB())
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"list_active_subscriptions",
lambda limit=200: [
{
"user_id": "user-1",
"plan_code": "pro_monthly",
"starts_at": "2026-03-22T00:00:00+00:00",
"expires_at": "2026-04-21T00:00:00+00:00",
}
],
)
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"get_auth_users",
lambda user_ids: {
"user-1": {
"email": "fresh@example.com",
"created_at": "2026-03-02T00:00:00+00:00",
}
},
)
response = client.get("/api/ops/memberships")
assert response.status_code == 200
payload = response.json()
assert payload["memberships"][0]["email"] == "fresh@example.com"
def test_ops_truth_history_returns_filtered_rows(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
repo = TruthRecordRepository()
repo.upsert_truth(
city="taipei",
target_date="2026-04-02",
actual_high=26.0,
settlement_source="wunderground",
settlement_station_code="RCSS",
settlement_station_label="Taipei Songshan Airport Station",
truth_version="v1",
updated_by="test",
source_payload={"sample": True},
is_final=True,
)
response = client.get("/api/ops/truth-history?city=taipei&date_from=2026-04-01&date_to=2026-04-03&limit=10")
assert response.status_code == 200
payload = response.json()
assert "items" in payload
assert payload["filters"]["city"] == "taipei"
assert payload["items"][0]["city"] == "taipei"
def test_scan_terminal_service_returns_stale_payload_after_failed_refresh(monkeypatch):
filters = {"scan_mode": "tradable", "limit": 5}
normalized_filters = scan_terminal_service._normalize_scan_terminal_filters(filters)
scan_terminal_cache._SCAN_TERMINAL_CACHE.clear()
monkeypatch.setattr(
scan_terminal_service,
"_scan_city_terminal_rows",
lambda *_args, **_kwargs: {
"city": "taipei",
"rows": [
{
"id": "row-1",
"market_key": "market-1",
"edge_percent": 12.4,
"final_score": 83.0,
"volume": 2000,
}
],
"candidate_total": 1,
"primary_scores": [83.0],
},
)
ready = scan_terminal_service.build_scan_terminal_payload(filters, force_refresh=True)
assert ready["status"] == "ready"
assert ready["rows"][0]["id"] == "row-1"
def _explode(*_args, **_kwargs):
raise RuntimeError("upstream 504")
monkeypatch.setattr(scan_terminal_service, "_scan_city_terminal_rows", _explode)
stale = scan_terminal_service.build_scan_terminal_payload(filters, force_refresh=True)
assert stale["status"] == "stale"
assert stale["stale"] is True
assert stale["rows"][0]["id"] == "row-1"
assert stale["filters"] == normalized_filters
assert stale["stale_reason"] == "upstream 504"
def test_scan_terminal_service_returns_failed_without_success_snapshot(monkeypatch):
filters = {"scan_mode": "tradable", "limit": 5}
scan_terminal_cache._SCAN_TERMINAL_CACHE.clear()
def _explode(*_args, **_kwargs):
raise RuntimeError("network down")
monkeypatch.setattr(scan_terminal_service, "_scan_city_terminal_rows", _explode)
failed = scan_terminal_service.build_scan_terminal_payload(filters, force_refresh=True)
assert failed["status"] == "failed"
assert failed["stale"] is False
assert failed["rows"] == []
assert failed["summary"]["candidate_total"] == 0
assert failed["stale_reason"] == "network down"
def test_scan_terminal_endpoint_forwards_filters(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
captured = {}
def _fake_build_scan_terminal_payload(filters, *, force_refresh=False):
captured["filters"] = dict(filters)
captured["force_refresh"] = force_refresh
return {
"generated_at": "2026-04-23T00:00:00Z",
"filters": filters,
"summary": {
"recommended_count": 1,
"visible_count": 1,
"candidate_total": 3,
"avg_edge_percent": 4.2,
"avg_primary_confidence": 88.0,
"tradable_market_count": 1,
"total_volume": 1500,
"resolved_market_type": "maxtemp",
},
"top_signal": None,
"rows": [],
}
monkeypatch.setattr(routes, "build_scan_terminal_payload", _fake_build_scan_terminal_payload)
response = client.get(
"/api/scan/terminal?scan_mode=trend&min_price=0.1&max_price=0.8&min_edge_pct=3"
"&min_liquidity=700&high_liquidity_only=true&market_type=all&time_range=week&limit=12&force_refresh=true"
)
assert response.status_code == 200
payload = response.json()
assert payload["summary"]["recommended_count"] == 1
assert captured["force_refresh"] is True
assert captured["filters"]["scan_mode"] == "trend"
assert captured["filters"]["market_type"] == "all"
assert captured["filters"]["time_range"] == "week"
assert captured["filters"]["limit"] == 12
def test_scan_terminal_cache_key_includes_filter_dimensions():
first = scan_terminal_cache_key(
{
"scan_mode": "tradable",
"time_range": "today",
"limit": 25,
}
)
second = scan_terminal_cache_key(
{
"scan_mode": "trend",
"time_range": "week",
"limit": 10,
}
)
assert first != second
assert "trend" in second
assert "week" in second