220 lines
7.2 KiB
Python
220 lines
7.2 KiB
Python
|
|
"""HTTP contract tests for the Home market-data overview."""
|
||
|
|
|
||
|
|
from datetime import UTC, date, datetime
|
||
|
|
from decimal import Decimal
|
||
|
|
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
|
||
|
|
from zhixing_server.bootstrap.app import create_app
|
||
|
|
from zhixing_server.modules.market_data.application.overview import (
|
||
|
|
MarketDataOverviewReader,
|
||
|
|
)
|
||
|
|
from zhixing_server.modules.market_data.domain.overview import (
|
||
|
|
MarketDataOverview,
|
||
|
|
MarketDataUpdate,
|
||
|
|
OverviewBatchError,
|
||
|
|
OverviewFailure,
|
||
|
|
)
|
||
|
|
from zhixing_server.modules.market_data.presentation.home import (
|
||
|
|
get_market_data_overview_reader,
|
||
|
|
)
|
||
|
|
|
||
|
|
TARGET_DATE = date(2026, 8, 6)
|
||
|
|
|
||
|
|
|
||
|
|
class FakeOverviewReader:
|
||
|
|
"""Small in-memory read adapter used at the HTTP dependency seam."""
|
||
|
|
|
||
|
|
def __init__(self, overview: MarketDataOverview) -> None:
|
||
|
|
self.overview = overview
|
||
|
|
|
||
|
|
def read_overview(self) -> MarketDataOverview:
|
||
|
|
return self.overview
|
||
|
|
|
||
|
|
|
||
|
|
def get_client(overview: MarketDataOverview) -> TestClient:
|
||
|
|
"""Build the real application with only its read adapter overridden."""
|
||
|
|
|
||
|
|
application = create_app()
|
||
|
|
reader = FakeOverviewReader(overview)
|
||
|
|
|
||
|
|
def override_reader() -> MarketDataOverviewReader:
|
||
|
|
return reader
|
||
|
|
|
||
|
|
application.dependency_overrides[get_market_data_overview_reader] = override_reader
|
||
|
|
return TestClient(application)
|
||
|
|
|
||
|
|
|
||
|
|
def test_home_overview_returns_complete_success_contract() -> None:
|
||
|
|
"""A complete batch exposes active-pool counts and Shanghai completion time."""
|
||
|
|
|
||
|
|
response = get_client(
|
||
|
|
MarketDataOverview(
|
||
|
|
status="success",
|
||
|
|
stock_pool_count=2,
|
||
|
|
latest_update=MarketDataUpdate(
|
||
|
|
trade_date=TARGET_DATE,
|
||
|
|
completed_at=datetime(2026, 8, 7, 8, 30, tzinfo=UTC),
|
||
|
|
status="success",
|
||
|
|
target_count=2,
|
||
|
|
successful_count=2,
|
||
|
|
failed_count=0,
|
||
|
|
valid_count=2,
|
||
|
|
coverage_rate=Decimal("1"),
|
||
|
|
failures=(),
|
||
|
|
batch_errors=(),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
).get("/api/v1/home/overview")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert response.json() == {
|
||
|
|
"status": "success",
|
||
|
|
"stock_pool_count": 2,
|
||
|
|
"latest_update": {
|
||
|
|
"trade_date": "2026-08-06",
|
||
|
|
"completed_at": "2026-08-07T16:30:00+08:00",
|
||
|
|
"status": "success",
|
||
|
|
"target_count": 2,
|
||
|
|
"successful_count": 2,
|
||
|
|
"failed_count": 0,
|
||
|
|
"valid_count": 2,
|
||
|
|
"coverage_rate": 1.0,
|
||
|
|
"failures": [],
|
||
|
|
"batch_errors": [],
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def test_home_overview_keeps_stock_and_batch_failures_separate() -> None:
|
||
|
|
"""Partial results preserve failure categories and batch-level errors."""
|
||
|
|
|
||
|
|
response = get_client(
|
||
|
|
MarketDataOverview(
|
||
|
|
status="partial_success",
|
||
|
|
stock_pool_count=3,
|
||
|
|
latest_update=MarketDataUpdate(
|
||
|
|
trade_date=TARGET_DATE,
|
||
|
|
completed_at=datetime(2026, 8, 6, 16, 30),
|
||
|
|
status="partial_success",
|
||
|
|
target_count=3,
|
||
|
|
successful_count=1,
|
||
|
|
failed_count=2,
|
||
|
|
valid_count=1,
|
||
|
|
coverage_rate=Decimal("0.3333333333"),
|
||
|
|
failures=(
|
||
|
|
OverviewFailure(
|
||
|
|
ts_code="000001.SZ",
|
||
|
|
name="平安银行",
|
||
|
|
failure_type="missing_daily_bar",
|
||
|
|
reason="目标交易日缺少日线行情",
|
||
|
|
trade_date=TARGET_DATE,
|
||
|
|
),
|
||
|
|
OverviewFailure(
|
||
|
|
ts_code="600000.SH",
|
||
|
|
name="浦发银行",
|
||
|
|
failure_type="recorded_stock_failure",
|
||
|
|
reason="供应商请求失败",
|
||
|
|
trade_date=TARGET_DATE,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
batch_errors=(
|
||
|
|
OverviewBatchError(
|
||
|
|
item_key="retention",
|
||
|
|
error_type="database_error",
|
||
|
|
reason="保留策略执行失败",
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
).get("/api/v1/home/overview")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
payload = response.json()
|
||
|
|
assert payload["latest_update"]["failed_count"] == 2
|
||
|
|
assert payload["latest_update"]["coverage_rate"] == 0.3333333333
|
||
|
|
assert payload["latest_update"]["failures"][0]["failure_type"] == "missing_daily_bar"
|
||
|
|
assert payload["latest_update"]["batch_errors"] == [
|
||
|
|
{
|
||
|
|
"item_key": "retention",
|
||
|
|
"error_type": "database_error",
|
||
|
|
"reason": "保留策略执行失败",
|
||
|
|
}
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def test_home_overview_returns_updating_without_completion_time() -> None:
|
||
|
|
"""A running batch remains distinguishable from an absent batch."""
|
||
|
|
|
||
|
|
response = get_client(
|
||
|
|
MarketDataOverview(
|
||
|
|
status="updating",
|
||
|
|
stock_pool_count=1,
|
||
|
|
latest_update=MarketDataUpdate(
|
||
|
|
trade_date=TARGET_DATE,
|
||
|
|
completed_at=None,
|
||
|
|
status="updating",
|
||
|
|
target_count=1,
|
||
|
|
successful_count=0,
|
||
|
|
failed_count=1,
|
||
|
|
valid_count=0,
|
||
|
|
coverage_rate=Decimal("0"),
|
||
|
|
failures=(),
|
||
|
|
batch_errors=(),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
).get("/api/v1/home/overview")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert response.json()["status"] == "updating"
|
||
|
|
assert response.json()["latest_update"]["completed_at"] is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_home_overview_returns_failed_batch_without_fabricating_stock_errors() -> None:
|
||
|
|
"""A failed batch keeps infrastructure errors separate from stock details."""
|
||
|
|
|
||
|
|
response = get_client(
|
||
|
|
MarketDataOverview(
|
||
|
|
status="failed",
|
||
|
|
stock_pool_count=2,
|
||
|
|
latest_update=MarketDataUpdate(
|
||
|
|
trade_date=TARGET_DATE,
|
||
|
|
completed_at=datetime(2026, 8, 6, 16, 30),
|
||
|
|
status="failed",
|
||
|
|
target_count=2,
|
||
|
|
successful_count=0,
|
||
|
|
failed_count=2,
|
||
|
|
valid_count=0,
|
||
|
|
coverage_rate=Decimal("0"),
|
||
|
|
failures=(),
|
||
|
|
batch_errors=(
|
||
|
|
OverviewBatchError(
|
||
|
|
item_key="retention",
|
||
|
|
error_type="database_error",
|
||
|
|
reason="保留策略执行失败",
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
).get("/api/v1/home/overview")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert response.json()["status"] == "failed"
|
||
|
|
assert response.json()["latest_update"]["failures"] == []
|
||
|
|
assert response.json()["latest_update"]["batch_errors"][0]["item_key"] == "retention"
|
||
|
|
|
||
|
|
|
||
|
|
def test_home_overview_returns_no_data_without_fake_update() -> None:
|
||
|
|
"""No synchronization batch is represented by a null latest update."""
|
||
|
|
|
||
|
|
response = get_client(
|
||
|
|
MarketDataOverview(status="no_data", stock_pool_count=7, latest_update=None)
|
||
|
|
).get("/api/v1/home/overview")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert response.json() == {
|
||
|
|
"status": "no_data",
|
||
|
|
"stock_pool_count": 7,
|
||
|
|
"latest_update": None,
|
||
|
|
}
|