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