import asyncio from datetime import UTC, datetime, timedelta from decimal import Decimal import httpx import pytest from backend import today_environment as environment SINA_QUOTE = ( 'var hq_str_sh000001="上证指数,3925.3230,3936.5199,3888.3738,3930.4955,3888.3738,0,0,' "438530412,783613001376,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0," '2026-09-24,15:36:00,00,";' ) OIL_PAYLOAD = { "data": { "provinceData": { "GAS_92": 8.58, "GAS_95": 9.12, "AIPAO_GAS_98": 11.12, "CHECHAI_0": 8.28, "START_DATE": "2026-09-24 00:12:00", } } } AIR_PAYLOAD = { "timezone": "Asia/Shanghai", "current": { "time": "2026-09-16T14:00", "pm2_5": 25.5, "pm10": 33.8, }, } @pytest.mark.parametrize( ("pm2_5", "pm10", "expected"), [ (0.0, 0.0, 0), (35.0, 0.0, 50), (36.0, 0.0, 51), (75.0, 0.0, 100), (115.0, 0.0, 150), (150.0, 0.0, 200), (250.0, 0.0, 300), (350.0, 0.0, 400), (500.0, 0.0, 500), (600.0, 0.0, 500), (25.5, 0.0, 36), (0.0, 50.0, 50), (0.0, 100.0, 75), (0.0, 150.0, 100), (0.0, 600.0, 500), (10.0, 100.0, 75), (75.0, 10.0, 100), ], ) def test_china_aqi_interpolates_breakpoints_and_uses_the_worst_pollutant(pm2_5, pm10, expected): assert environment.china_aqi(pm2_5, pm10) == expected @pytest.mark.parametrize( ("aqi", "expected"), [ (0, "优"), (50, "优"), (51, "良"), (100, "良"), (101, "轻度污染"), (150, "轻度污染"), (151, "中度污染"), (200, "中度污染"), (201, "重度污染"), (300, "重度污染"), (301, "严重污染"), (500, "严重污染"), ], ) def test_aqi_level_boundaries(aqi, expected): assert environment.aqi_level(aqi) == expected @pytest.mark.asyncio async def test_fetch_air_uses_fixed_haishu_location_and_chinese_aqi(): seen = {} async def request(url, **kwargs): seen["url"] = url seen["params"] = kwargs["params"] return AIR_PAYLOAD result = await environment.fetch_air(request) assert seen == { "url": environment.AIR_URL, "params": { "latitude": 29.88, "longitude": 121.55, "current": "pm2_5,pm10", "timezone": "Asia/Shanghai", }, } assert result == { "aqi": 36, "level": "优", "pm2_5": 25.5, "pm10": 33.8, "observed_at": "2026-09-16T14:00:00+08:00", "source": "Open-Meteo 空气质量", } @pytest.mark.asyncio @pytest.mark.parametrize( "payload", [ {**AIR_PAYLOAD, "timezone": "UTC"}, {**AIR_PAYLOAD, "current": {}}, {**AIR_PAYLOAD, "current": {**AIR_PAYLOAD["current"], "pm2_5": True}}, {**AIR_PAYLOAD, "current": {**AIR_PAYLOAD["current"], "pm10": float("nan")}}, {**AIR_PAYLOAD, "current": {**AIR_PAYLOAD["current"], "pm2_5": -1}}, {**AIR_PAYLOAD, "current": {**AIR_PAYLOAD["current"], "pm10": 5000}}, {**AIR_PAYLOAD, "current": {**AIR_PAYLOAD["current"], "time": "2026-13-40T00:00"}}, {**AIR_PAYLOAD, "current": {**AIR_PAYLOAD["current"], "time": "2026-09-16T14:00+00:00"}}, {"timezone": "Asia/Shanghai"}, {"timezone": "Asia/Shanghai", "current": []}, ], ) async def test_fetch_air_rejects_invalid_contract(payload): async def request(*_args, **_kwargs): return payload with pytest.raises((KeyError, TypeError, ValueError)): await environment.fetch_air(request) def test_parse_sina_index_returns_price_change_and_quote_date(): result = environment.parse_sina_index(SINA_QUOTE) assert result == { "name": "上证指数", "price": Decimal("3888.3738"), "prev_close": Decimal("3936.5199"), "change_percent": -1.22, "as_of": "2026-09-24", "source": "新浪财经", } @pytest.mark.parametrize( "text", [ "", "var hq_str_sh000001=", 'var hq_str_sz399001="深证成指,1,2,3,4";', 'var hq_str_sh000001="上证指数,1,2";', 'var hq_str_sh000001="上证指数,0,0,3888.37,1";', 'var hq_str_sh000001="上证指数,3925.32,3936.52,abc,1";', 'var hq_str_sh000001="上证指数,3925.32,3936.52,3888.37";', 'var hq_str_sh000001="上证指数,3925.32,3936.52,3888.37,1,1,2026-13-40";', ], ) def test_parse_sina_index_rejects_unexpected_quotes(text): with pytest.raises(ValueError): environment.parse_sina_index(text) @pytest.mark.asyncio async def test_fetch_ashare_decodes_gbk_quote_bytes(): seen = {} async def request(url, **kwargs): seen["url"] = url seen["headers"] = kwargs["headers"] return SINA_QUOTE.encode("gbk") result = await environment.fetch_ashare(request) assert seen["url"] == environment.ASHARE_URL assert seen["headers"] == environment.ASHARE_HEADERS assert result["change_percent"] == -1.22 @pytest.mark.asyncio async def test_fetch_ashare_rejects_non_gbk_bytes(): async def request(*_args, **_kwargs): return b'\x80\x81\x82 "' with pytest.raises(ValueError): await environment.fetch_ashare(request) def test_parse_oil_payload_reads_zhejiang_prices_and_effective_date(): result = environment.parse_oil_payload(OIL_PAYLOAD) assert result == { "gas_92": Decimal("8.58"), "gas_95": Decimal("9.12"), "gas_98": Decimal("11.12"), "diesel_0": Decimal("8.28"), "effective_at": "2026-09-24T00:12:00+08:00", "source": "中国石化", } @pytest.mark.parametrize( "payload", [ [], {}, {"data": {}}, {"data": {"provinceData": []}}, {"data": {"provinceData": {"GAS_95": 9.12, "AIPAO_GAS_98": 11.12, "CHECHAI_0": 8.28}}}, {"data": {"provinceData": {**OIL_PAYLOAD["data"]["provinceData"], "GAS_92": "abc"}}}, {"data": {"provinceData": {**OIL_PAYLOAD["data"]["provinceData"], "GAS_92": 200}}}, {"data": {"provinceData": {**OIL_PAYLOAD["data"]["provinceData"], "GAS_92": float("nan")}}}, {"data": {"provinceData": {**OIL_PAYLOAD["data"]["provinceData"], "GAS_92": True}}}, ], ) def test_parse_oil_payload_rejects_invalid_structure_or_prices(payload): with pytest.raises((TypeError, ValueError)): environment.parse_oil_payload(payload) def test_parse_oil_payload_keeps_prices_when_effective_date_is_unparsable(): payload = { "data": { "provinceData": { **OIL_PAYLOAD["data"]["provinceData"], "START_DATE": "not-a-date", } } } result = environment.parse_oil_payload(payload) assert result["gas_92"] == Decimal("8.58") assert result["effective_at"] is None def _oil_client(handler): return httpx.AsyncClient(transport=httpx.MockTransport(handler)) @pytest.mark.asyncio async def test_fetch_oil_runs_session_switch_then_init_with_cookies(): calls = [] def handler(request: httpx.Request) -> httpx.Response: calls.append((request.method, request.url.path)) if request.url.path == environment.OIL_MAIN_PATH: return httpx.Response( 200, headers={"set-cookie": "SESSION=abc123; Path=/"} ) if request.url.path == environment.OIL_SWITCH_PATH: assert request.content == b'{"provinceId":"33"}' return httpx.Response(200, json={"success": True}) if request.url.path == environment.OIL_INIT_PATH: assert request.headers.get("cookie") == "SESSION=abc123" assert request.headers.get("referer", "").endswith(environment.OIL_MAIN_PATH) return httpx.Response(200, json=OIL_PAYLOAD) raise AssertionError(f"unexpected request: {request.url}") async with _oil_client(handler) as client: result = await environment.fetch_oil(client) assert calls == [ ("GET", environment.OIL_MAIN_PATH), ("POST", environment.OIL_SWITCH_PATH), ("GET", environment.OIL_INIT_PATH), ] assert result["gas_92"] == Decimal("8.58") assert result["source"] == "中国石化" @pytest.mark.asyncio async def test_fetch_oil_requires_session_cookie(): def handler(request: httpx.Request) -> httpx.Response: if request.url.path == environment.OIL_MAIN_PATH: return httpx.Response(200) raise AssertionError("flow must stop without a session cookie") async with _oil_client(handler) as client: with pytest.raises(ValueError, match="session cookie"): await environment.fetch_oil(client) @pytest.mark.asyncio async def test_get_environment_extras_collects_sources_concurrently_and_serializes(): environment.clear_cache() both_started = asyncio.Event() started = set() def record(name): async def fetch(): started.add(name) if len(started) == 3: both_started.set() await asyncio.wait_for(both_started.wait(), timeout=0.1) if name == "air": return { "aqi": 36, "level": "优", "pm2_5": 25.5, "pm10": 33.8, "observed_at": "2026-09-16T14:00:00+08:00", "source": "Open-Meteo 空气质量", } if name == "oil": return environment.parse_oil_payload(OIL_PAYLOAD) return environment.parse_sina_index(SINA_QUOTE) return fetch result = await environment.get_environment_extras( air_fetcher=record("air"), oil_fetcher=record("oil"), ashare_fetcher=record("ashare"), ) assert started == {"air", "oil", "ashare"} assert result["air"]["aqi"] == 36 assert result["air"]["stale"] is False assert result["oil"]["gas_92"] == "8.58" assert result["oil"]["effective_at"] == "2026-09-24T00:12:00+08:00" assert result["ashare"]["price"] == "3888.3738" assert result["ashare"]["change_percent"] == -1.22 assert result["errors"] == {} @pytest.mark.asyncio async def test_get_environment_extras_keeps_partial_success_on_source_failure(): environment.clear_cache() async def air(): return { "aqi": 36, "level": "优", "pm2_5": 25.5, "pm10": 33.8, "observed_at": "2026-09-16T14:00:00+08:00", "source": "Open-Meteo 空气质量", } async def oil(): raise RuntimeError("sinopec down") async def ashare(): return environment.parse_sina_index(SINA_QUOTE) result = await environment.get_environment_extras( air_fetcher=air, oil_fetcher=oil, ashare_fetcher=ashare ) assert result["air"]["aqi"] == 36 assert result["oil"] is None assert result["ashare"]["name"] == "上证指数" assert result["errors"] == {"oil": "upstream_unavailable"} @pytest.mark.asyncio async def test_extras_sources_have_independent_ttl_and_serve_stale_on_error(monkeypatch): environment.clear_cache() base = datetime(2026, 9, 16, 8, tzinfo=UTC) monkeypatch.setattr(environment, "OIL_TTL", timedelta(hours=6), raising=False) calls = {"air": 0, "oil": 0} async def air(): calls["air"] += 1 if calls["air"] > 1: raise RuntimeError("air down") return { "aqi": 36, "level": "优", "pm2_5": 25.5, "pm10": 33.8, "observed_at": "2026-09-16T14:00:00+08:00", "source": "Open-Meteo 空气质量", } async def oil(): calls["oil"] += 1 return environment.parse_oil_payload(OIL_PAYLOAD) async def ashare(): return environment.parse_sina_index(SINA_QUOTE) await environment.get_environment_extras( now=base, air_fetcher=air, oil_fetcher=oil, ashare_fetcher=ashare ) # Air TTL is 1h: at +30min it is still fresh (no refetch), oil is fresh for 6h. after_thirty_minutes = await environment.get_environment_extras( now=base + timedelta(minutes=30), air_fetcher=air, oil_fetcher=oil, ashare_fetcher=ashare ) assert calls == {"air": 1, "oil": 1} assert after_thirty_minutes["air"]["stale"] is False # Past the air TTL a failing refetch serves the cached value flagged stale. after_two_hours = await environment.get_environment_extras( now=base + timedelta(hours=2), air_fetcher=air, oil_fetcher=oil, ashare_fetcher=ashare ) assert calls == {"air": 2, "oil": 1} assert after_two_hours["air"]["stale"] is True assert after_two_hours["errors"] == {"air": "upstream_unavailable"}