"""Integration tests for routes_cn.py - written FIRST (TDD RED phase).""" from unittest.mock import patch, AsyncMock import pytest from httpx import AsyncClient, ASGITransport from main import app @pytest.fixture async def client(): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c # ============================================================ # A-share quote GET /api/v1/cn/a-share/{symbol}/quote # ============================================================ @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_a_share_quote", new_callable=AsyncMock) async def test_a_share_quote_happy_path(mock_fn, client): mock_fn.return_value = { "symbol": "000001", "name": "平安银行", "price": 12.34, "change": 0.15, "change_percent": 1.23, "volume": 500_000, "turnover": 6_170_000.0, "open": 12.10, "high": 12.50, "low": 12.00, "prev_close": 12.19, } resp = await client.get("/api/v1/cn/a-share/000001/quote") assert resp.status_code == 200 data = resp.json() assert data["success"] is True assert data["data"]["symbol"] == "000001" assert data["data"]["name"] == "平安银行" assert data["data"]["price"] == pytest.approx(12.34) @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_a_share_quote", new_callable=AsyncMock) async def test_a_share_quote_not_found_returns_404(mock_fn, client): mock_fn.return_value = None resp = await client.get("/api/v1/cn/a-share/000001/quote") assert resp.status_code == 404 @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_a_share_quote", new_callable=AsyncMock) async def test_a_share_quote_service_error_returns_502(mock_fn, client): mock_fn.side_effect = RuntimeError("AKShare down") resp = await client.get("/api/v1/cn/a-share/000001/quote") assert resp.status_code == 502 @pytest.mark.asyncio async def test_a_share_quote_invalid_symbol_returns_400(client): # symbol starting with 1 is invalid for A-shares resp = await client.get("/api/v1/cn/a-share/100001/quote") assert resp.status_code == 400 @pytest.mark.asyncio async def test_a_share_quote_non_numeric_symbol_returns_400(client): resp = await client.get("/api/v1/cn/a-share/ABCDEF/quote") assert resp.status_code == 400 @pytest.mark.asyncio async def test_a_share_quote_too_short_returns_422(client): resp = await client.get("/api/v1/cn/a-share/00001/quote") assert resp.status_code == 422 # ============================================================ # A-share historical GET /api/v1/cn/a-share/{symbol}/historical # ============================================================ @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_a_share_historical", new_callable=AsyncMock) async def test_a_share_historical_happy_path(mock_fn, client): mock_fn.return_value = [ { "date": "2026-01-01", "open": 10.0, "close": 10.5, "high": 11.0, "low": 9.5, "volume": 1_000_000, "turnover": 10_500_000.0, "change_percent": 0.5, "turnover_rate": 0.3, } ] resp = await client.get("/api/v1/cn/a-share/000001/historical?days=30") assert resp.status_code == 200 data = resp.json() assert data["success"] is True assert isinstance(data["data"], list) assert len(data["data"]) == 1 assert data["data"][0]["date"] == "2026-01-01" @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_a_share_historical", new_callable=AsyncMock) async def test_a_share_historical_default_days(mock_fn, client): mock_fn.return_value = [] resp = await client.get("/api/v1/cn/a-share/600519/historical") assert resp.status_code == 200 mock_fn.assert_called_once_with("600519", days=365) @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_a_share_historical", new_callable=AsyncMock) async def test_a_share_historical_empty_returns_200(mock_fn, client): mock_fn.return_value = [] resp = await client.get("/api/v1/cn/a-share/000001/historical") assert resp.status_code == 200 assert resp.json()["data"] == [] @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_a_share_historical", new_callable=AsyncMock) async def test_a_share_historical_service_error_returns_502(mock_fn, client): mock_fn.side_effect = RuntimeError("AKShare down") resp = await client.get("/api/v1/cn/a-share/000001/historical") assert resp.status_code == 502 @pytest.mark.asyncio async def test_a_share_historical_invalid_symbol_returns_400(client): resp = await client.get("/api/v1/cn/a-share/100001/historical") assert resp.status_code == 400 @pytest.mark.asyncio async def test_a_share_historical_days_out_of_range_returns_422(client): resp = await client.get("/api/v1/cn/a-share/000001/historical?days=0") assert resp.status_code == 422 # ============================================================ # A-share search GET /api/v1/cn/a-share/search # ============================================================ @pytest.mark.asyncio @patch("routes_cn.akshare_service.search_a_shares", new_callable=AsyncMock) async def test_a_share_search_happy_path(mock_fn, client): mock_fn.return_value = [ {"code": "000001", "name": "平安银行"}, {"code": "000002", "name": "平安地产"}, ] resp = await client.get("/api/v1/cn/a-share/search?query=平安") assert resp.status_code == 200 data = resp.json() assert data["success"] is True assert len(data["data"]) == 2 assert data["data"][0]["code"] == "000001" @pytest.mark.asyncio @patch("routes_cn.akshare_service.search_a_shares", new_callable=AsyncMock) async def test_a_share_search_empty_results(mock_fn, client): mock_fn.return_value = [] resp = await client.get("/api/v1/cn/a-share/search?query=NOMATCH") assert resp.status_code == 200 assert resp.json()["data"] == [] @pytest.mark.asyncio @patch("routes_cn.akshare_service.search_a_shares", new_callable=AsyncMock) async def test_a_share_search_service_error_returns_502(mock_fn, client): mock_fn.side_effect = RuntimeError("AKShare down") resp = await client.get("/api/v1/cn/a-share/search?query=test") assert resp.status_code == 502 @pytest.mark.asyncio async def test_a_share_search_missing_query_returns_422(client): resp = await client.get("/api/v1/cn/a-share/search") assert resp.status_code == 422 # ============================================================ # HK quote GET /api/v1/cn/hk/{symbol}/quote # ============================================================ @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_hk_quote", new_callable=AsyncMock) async def test_hk_quote_happy_path(mock_fn, client): mock_fn.return_value = { "symbol": "00700", "name": "腾讯控股", "price": 380.0, "change": 5.0, "change_percent": 1.33, "volume": 10_000_000, "turnover": 3_800_000_000.0, "open": 375.0, "high": 385.0, "low": 374.0, "prev_close": 375.0, } resp = await client.get("/api/v1/cn/hk/00700/quote") assert resp.status_code == 200 data = resp.json() assert data["success"] is True assert data["data"]["symbol"] == "00700" assert data["data"]["price"] == pytest.approx(380.0) @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_hk_quote", new_callable=AsyncMock) async def test_hk_quote_not_found_returns_404(mock_fn, client): mock_fn.return_value = None resp = await client.get("/api/v1/cn/hk/00700/quote") assert resp.status_code == 404 @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_hk_quote", new_callable=AsyncMock) async def test_hk_quote_service_error_returns_502(mock_fn, client): mock_fn.side_effect = RuntimeError("AKShare down") resp = await client.get("/api/v1/cn/hk/00700/quote") assert resp.status_code == 502 @pytest.mark.asyncio async def test_hk_quote_invalid_symbol_letters_returns_400(client): resp = await client.get("/api/v1/cn/hk/ABCDE/quote") assert resp.status_code == 400 @pytest.mark.asyncio async def test_hk_quote_too_short_returns_422(client): resp = await client.get("/api/v1/cn/hk/0070/quote") assert resp.status_code == 422 @pytest.mark.asyncio async def test_hk_quote_too_long_returns_422(client): resp = await client.get("/api/v1/cn/hk/007000/quote") assert resp.status_code == 422 # ============================================================ # HK historical GET /api/v1/cn/hk/{symbol}/historical # ============================================================ @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_hk_historical", new_callable=AsyncMock) async def test_hk_historical_happy_path(mock_fn, client): mock_fn.return_value = [ { "date": "2026-01-01", "open": 375.0, "close": 380.0, "high": 385.0, "low": 374.0, "volume": 10_000_000, "turnover": 3_800_000_000.0, "change_percent": 1.33, "turnover_rate": 0.5, } ] resp = await client.get("/api/v1/cn/hk/00700/historical?days=90") assert resp.status_code == 200 data = resp.json() assert data["success"] is True assert isinstance(data["data"], list) assert data["data"][0]["close"] == pytest.approx(380.0) @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_hk_historical", new_callable=AsyncMock) async def test_hk_historical_default_days(mock_fn, client): mock_fn.return_value = [] resp = await client.get("/api/v1/cn/hk/09988/historical") assert resp.status_code == 200 mock_fn.assert_called_once_with("09988", days=365) @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_hk_historical", new_callable=AsyncMock) async def test_hk_historical_empty_returns_200(mock_fn, client): mock_fn.return_value = [] resp = await client.get("/api/v1/cn/hk/00700/historical") assert resp.status_code == 200 assert resp.json()["data"] == [] @pytest.mark.asyncio @patch("routes_cn.akshare_service.get_hk_historical", new_callable=AsyncMock) async def test_hk_historical_service_error_returns_502(mock_fn, client): mock_fn.side_effect = RuntimeError("AKShare down") resp = await client.get("/api/v1/cn/hk/00700/historical") assert resp.status_code == 502 @pytest.mark.asyncio async def test_hk_historical_invalid_symbol_returns_400(client): resp = await client.get("/api/v1/cn/hk/ABCDE/historical") assert resp.status_code == 400 @pytest.mark.asyncio async def test_hk_historical_days_out_of_range_returns_422(client): resp = await client.get("/api/v1/cn/hk/00700/historical?days=0") assert resp.status_code == 422