Files
zhixing-system/zhixing-server/tests/test_sector_radar_http.py
T

231 lines
7.6 KiB
Python
Raw Normal View History

from dataclasses import replace
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from zhixing_server.bootstrap.app import create_app
from zhixing_server.modules.sector_radar.application.read import (
RadarDateIndex,
RadarMetricDefinition,
RadarQuery,
RadarView,
RankingPage,
)
from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy
from zhixing_server.modules.sector_radar.domain.models import (
MetricKind,
MetricObservation,
MetricQuality,
MetricUnit,
PublicationStatus,
RadarPublication,
RankChange,
RankedMetric,
RankSide,
SectorType,
)
from zhixing_server.modules.sector_radar.infrastructure.postgres import (
SectorRadarRepositoryError,
)
from zhixing_server.modules.sector_radar.presentation.http import (
RadarRankingRowResponse,
get_sector_radar_reader,
)
TARGET_DATE = date(2026, 8, 28)
NOW = datetime(2026, 8, 28, 17, 30, tzinfo=UTC)
def _publication(
publication_id: str,
status: PublicationStatus,
*,
trade_date: date = TARGET_DATE,
) -> RadarPublication:
return RadarPublication(
publication_id=publication_id,
target_trade_date=trade_date,
status=status,
source_version="tushare-pro-v1",
universe_version="eastmoney-dc-v1",
metric_versions=(AmountNetStrategy.metric_version,),
input_hash="a" * 64 if status is PublicationStatus.SUCCESS else None,
coverage=Decimal(1) if status is PublicationStatus.SUCCESS else Decimal("0.8"),
started_at=NOW,
finished_at=None if status is PublicationStatus.RUNNING else NOW + timedelta(minutes=5),
error_summary=None if status is PublicationStatus.SUCCESS else "safe_error",
)
def _ranking() -> RankedMetric:
return RankedMetric(
observation=MetricObservation(
trade_date=TARGET_DATE,
sector_type=SectorType.CONCEPT,
sector_code="BK0001.DC",
sector_name="机器人",
metric_kind=MetricKind.AMOUNT,
metric_version=AmountNetStrategy.metric_version,
implementation_kind="independent",
unit=MetricUnit.CNY_100M,
value=Decimal("12.5"),
quality=MetricQuality.AVAILABLE,
member_count=20,
valid_sample_count=19,
membership_coverage=Decimal(1),
moneyflow_coverage=Decimal("0.95"),
),
rank_position=1,
rank_percentile=Decimal(100),
rank_changes=(RankChange(days=5, value=3),),
)
class FakeReader:
def __init__(self, *, no_data: bool = False, fail: bool = False) -> None:
self.fail = fail
self.last_query: RadarQuery | None = None
success = _publication("publication-success", PublicationStatus.SUCCESS)
current = _publication(
"publication-partial",
PublicationStatus.PARTIAL,
trade_date=TARGET_DATE + timedelta(days=1),
)
self.date_index = RadarDateIndex(
available_dates=() if no_data else (TARGET_DATE,),
current_attempt=None if no_data else current,
last_good=None if no_data else success,
)
query = RadarQuery()
self.page = RankingPage(
status="no_data" if no_data else "success",
query=query,
publication=None if no_data else success,
definition=RadarMetricDefinition(
metric_kind=MetricKind.AMOUNT,
metric_version=AmountNetStrategy.metric_version,
label="主力净流入(知行独立实现)",
unit=MetricUnit.CNY_100M,
),
rows=() if no_data else (_ranking(),),
total=0 if no_data else 1,
)
def list_dates(self) -> RadarDateIndex:
if self.fail:
raise SectorRadarRepositoryError("private database detail")
return self.date_index
def query(self, query: RadarQuery) -> RankingPage:
if self.fail:
raise SectorRadarRepositoryError("private database detail")
self.last_query = query
return replace(self.page, query=query)
def _client(reader: FakeReader) -> TestClient:
application = create_app()
application.dependency_overrides[get_sector_radar_reader] = lambda: reader
return TestClient(application)
def test_dates_exposes_partial_attempt_without_replacing_last_good() -> None:
response = _client(FakeReader()).get("/api/v1/sector-radar/dates")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "success"
assert payload["available_dates"] == ["2026-08-28"]
assert payload["current_attempt"]["status"] == "partial"
assert payload["last_good"]["status"] == "success"
assert payload["last_good"]["coverage"] == "1"
def test_rankings_maps_filters_and_independent_metric_contract() -> None:
reader = FakeReader()
response = _client(reader).get(
"/api/v1/sector-radar/rankings",
params={
"trade_date": "2026-08-28",
"sector_type": "concept",
"view": "rank_change",
"rank_change_metric": "amount",
"rank_change_days": 5,
"side": "top",
"search": " 机器人 ",
"page": 2,
"page_size": 10,
},
)
assert response.status_code == 200
assert reader.last_query == RadarQuery(
trade_date=TARGET_DATE,
sector_type=SectorType.CONCEPT,
view=RadarView.RANK_CHANGE,
rank_change_metric=MetricKind.AMOUNT,
rank_change_days=5,
side=RankSide.TOP,
search="机器人",
page=2,
page_size=10,
)
payload = response.json()
assert payload["definition"]["metric_version"] == "zhixing_amount_net_bn_v1"
assert payload["definition"]["implementation_kind"] == "independent"
assert "知行独立实现" in payload["definition"]["disclaimer"]
assert payload["rows"][0]["rank_change"] == 3
assert payload["rows"][0]["unit"] == "CNY_100M"
def test_no_data_is_a_stable_200_response() -> None:
client = _client(FakeReader(no_data=True))
dates = client.get("/api/v1/sector-radar/dates")
rankings = client.get("/api/v1/sector-radar/rankings")
assert dates.status_code == 200
assert dates.json()["status"] == "no_data"
assert rankings.status_code == 200
assert rankings.json()["status"] == "no_data"
assert rankings.json()["rows"] == []
def test_http_contract_rejects_zero_rank_percentile() -> None:
payload = _client(FakeReader()).get("/api/v1/sector-radar/rankings").json()["rows"][0]
payload["rank_percentile"] = "0"
with pytest.raises(ValidationError):
RadarRankingRowResponse.model_validate(payload)
def test_invalid_query_values_return_422() -> None:
client = _client(FakeReader())
for params in (
{"rank_change_days": 0},
{"rank_change_days": 6},
{"page": 0},
{"page_size": 101},
{"sector_type": "region"},
{"view": "unknown"},
{"side": "unknown"},
):
assert client.get("/api/v1/sector-radar/rankings", params=params).status_code == 422
def test_repository_error_maps_to_redacted_503() -> None:
response = _client(FakeReader(fail=True)).get("/api/v1/sector-radar/rankings")
assert response.status_code == 503
assert response.json() == {
"detail": {
"code": "sector_radar_storage_unavailable",
"message": "sector radar storage is unavailable",
}
}
assert "private database detail" not in response.text