5 new endpoints under /api/v1/cn/:
- GET /cn/a-share/{symbol}/quote - A-share real-time quote
- GET /cn/a-share/{symbol}/historical - A-share historical OHLCV
- GET /cn/a-share/search?query= - search A-shares by name
- GET /cn/hk/{symbol}/quote - HK stock real-time quote
- GET /cn/hk/{symbol}/historical - HK stock historical OHLCV
Features:
- Chinese column names auto-mapped to English
- Symbol validation: A-share ^[036]\d{5}$, HK ^\d{5}$
- qfq (forward-adjusted) prices by default
- 79 new tests (51 service unit + 28 route integration)
- All 470 tests passing
322 lines
11 KiB
Python
322 lines
11 KiB
Python
"""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
|