fix(sector-radar): 收紧可复现发布契约
This commit is contained in:
@@ -2,7 +2,9 @@ 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 (
|
||||
@@ -29,6 +31,7 @@ from zhixing_server.modules.sector_radar.infrastructure.postgres import (
|
||||
SectorRadarRepositoryError,
|
||||
)
|
||||
from zhixing_server.modules.sector_radar.presentation.http import (
|
||||
RadarRankingRowResponse,
|
||||
get_sector_radar_reader,
|
||||
)
|
||||
|
||||
@@ -191,6 +194,14 @@ def test_no_data_is_a_stable_200_response() -> None:
|
||||
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())
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from zhixing_server.modules.sector_radar.application.build import (
|
||||
BuildSectorRadarCommand,
|
||||
)
|
||||
from zhixing_server.modules.sector_radar.domain.models import (
|
||||
MembershipStatus,
|
||||
PublicationStatus,
|
||||
RadarPublication,
|
||||
SectorType,
|
||||
@@ -19,6 +20,7 @@ from zhixing_server.modules.sector_radar.domain.source import (
|
||||
SectorIndexRow,
|
||||
SectorMemberRow,
|
||||
SourceResult,
|
||||
SourceSnapshot,
|
||||
StockBasicRow,
|
||||
SuspendRow,
|
||||
TradeCalendarRow,
|
||||
@@ -37,9 +39,11 @@ class FakeRadarSource:
|
||||
self,
|
||||
*,
|
||||
missing_moneyflow: bool = False,
|
||||
missing_membership: bool = False,
|
||||
net_scale: Decimal = Decimal(1),
|
||||
) -> None:
|
||||
self.missing_moneyflow = missing_moneyflow
|
||||
self.missing_membership = missing_membership
|
||||
self.net_scale = net_scale
|
||||
self.fail_daily = False
|
||||
self.calls: list[str] = []
|
||||
@@ -153,6 +157,38 @@ class FakeRadarSource:
|
||||
self, trade_date: date, sector_codes: Sequence[str]
|
||||
) -> SourceResult[SectorMemberRow]:
|
||||
self.calls.append("members")
|
||||
if self.missing_membership:
|
||||
snapshots: list[SourceSnapshot] = []
|
||||
member_rows: list[SectorMemberRow] = []
|
||||
for sector_code in sector_codes:
|
||||
sector_rows = (
|
||||
()
|
||||
if sector_code == sector_codes[-1]
|
||||
else tuple(
|
||||
SectorMemberRow(
|
||||
trade_date,
|
||||
sector_code,
|
||||
f"00000{index}.SZ",
|
||||
f"股票{index}",
|
||||
)
|
||||
for index in range(1, 6)
|
||||
)
|
||||
)
|
||||
snapshots.append(
|
||||
build_source_snapshot(
|
||||
api_name="dc_member",
|
||||
params={
|
||||
"trade_date": trade_date.isoformat(),
|
||||
"ts_code": sector_code,
|
||||
},
|
||||
rows=tuple(self._raw_row(row) for row in sector_rows),
|
||||
target_trade_date=trade_date,
|
||||
partition_key=sector_code,
|
||||
observed_at=NOW,
|
||||
)
|
||||
)
|
||||
member_rows.extend(sector_rows)
|
||||
return SourceResult(tuple(snapshots), tuple(member_rows))
|
||||
rows = tuple(
|
||||
SectorMemberRow(trade_date, sector_code, f"00000{index}.SZ", f"股票{index}")
|
||||
for sector_code in sector_codes
|
||||
@@ -288,6 +324,33 @@ def test_partial_coverage_and_lock_have_distinct_exit_codes() -> None:
|
||||
assert locked.outcomes[0].status == "locked"
|
||||
|
||||
|
||||
def test_unknown_membership_is_persisted_as_partial_and_retried_independently() -> None:
|
||||
repository = InMemorySectorRadarRepository()
|
||||
source = FakeRadarSource(missing_membership=True)
|
||||
use_case = BuildSectorRadar(source, repository, today=TARGET_DATE, now_fn=lambda: NOW)
|
||||
|
||||
partial = use_case.execute(BuildSectorRadarCommand(trade_date=TARGET_DATE))
|
||||
partial_id = partial.outcomes[0].publication_id
|
||||
assert partial_id is not None
|
||||
publication = repository.get_publication(partial_id)
|
||||
assert partial.status == "partial"
|
||||
assert publication is not None
|
||||
assert publication.error_summary == "membership_unknown"
|
||||
assert any(item.status is MembershipStatus.UNKNOWN for item in repository.memberships.values())
|
||||
assert {
|
||||
record.source_group
|
||||
for record in repository.load_publication_sources(partial_id)
|
||||
if record.refresh_on_retry
|
||||
} == {PublicationSourceGroup.MEMBERS}
|
||||
|
||||
source.missing_membership = False
|
||||
source.calls.clear()
|
||||
retried = use_case.execute(BuildSectorRadarCommand(retry_publication_id=partial_id))
|
||||
|
||||
assert retried.status == "success"
|
||||
assert source.calls == ["members"]
|
||||
|
||||
|
||||
def test_range_builds_dates_in_order_and_retry_uses_old_target() -> None:
|
||||
repository = InMemorySectorRadarRepository()
|
||||
source = FakeRadarSource(missing_moneyflow=True)
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from zhixing_server.modules.sector_radar.domain.models import StockFactStatus
|
||||
from zhixing_server.modules.sector_radar.domain.normalize import normalize_stock_facts
|
||||
from zhixing_server.modules.sector_radar.domain.models import (
|
||||
MembershipStatus,
|
||||
SectorType,
|
||||
StockFactStatus,
|
||||
)
|
||||
from zhixing_server.modules.sector_radar.domain.normalize import (
|
||||
normalize_memberships,
|
||||
normalize_stock_facts,
|
||||
)
|
||||
from zhixing_server.modules.sector_radar.domain.source import (
|
||||
DailyRow,
|
||||
MoneyflowDcRow,
|
||||
SectorIndexRow,
|
||||
SectorMemberRow,
|
||||
SourceResult,
|
||||
StockBasicRow,
|
||||
SuspendRow,
|
||||
@@ -64,6 +73,69 @@ def moneyflow(ts_code: str, amount: Decimal | None) -> MoneyflowDcRow:
|
||||
)
|
||||
|
||||
|
||||
def test_membership_normalization_persists_an_explicit_unknown_sector() -> None:
|
||||
indices = (
|
||||
SectorIndexRow(
|
||||
TARGET_DATE,
|
||||
SectorType.CONCEPT,
|
||||
"BK0001.DC",
|
||||
"机器人",
|
||||
"一级",
|
||||
Decimal(1),
|
||||
None,
|
||||
),
|
||||
SectorIndexRow(
|
||||
TARGET_DATE,
|
||||
SectorType.CONCEPT,
|
||||
"BK0002.DC",
|
||||
"低空经济",
|
||||
"一级",
|
||||
Decimal(1),
|
||||
None,
|
||||
),
|
||||
)
|
||||
member = SectorMemberRow(
|
||||
TARGET_DATE,
|
||||
"BK0001.DC",
|
||||
"000001.SZ",
|
||||
"平安银行",
|
||||
)
|
||||
all_snapshot = build_source_snapshot(
|
||||
api_name="dc_member",
|
||||
params={"trade_date": "20260828"},
|
||||
rows=(
|
||||
{
|
||||
"trade_date": "20260828",
|
||||
"ts_code": member.sector_code,
|
||||
"con_code": member.stock_code,
|
||||
"name": member.stock_name,
|
||||
},
|
||||
),
|
||||
target_trade_date=TARGET_DATE,
|
||||
partition_key="all",
|
||||
observed_at=OBSERVED_AT,
|
||||
)
|
||||
empty_partition = build_source_snapshot(
|
||||
api_name="dc_member",
|
||||
params={"trade_date": "20260828", "ts_code": "BK0002.DC"},
|
||||
rows=(),
|
||||
target_trade_date=TARGET_DATE,
|
||||
partition_key="BK0002.DC",
|
||||
observed_at=OBSERVED_AT,
|
||||
)
|
||||
|
||||
records = normalize_memberships(
|
||||
indices,
|
||||
SourceResult((all_snapshot, empty_partition), (member,)),
|
||||
)
|
||||
|
||||
assert records[0].status is MembershipStatus.AVAILABLE
|
||||
assert records[0].stock_code == "000001.SZ"
|
||||
assert records[1].status is MembershipStatus.UNKNOWN
|
||||
assert records[1].stock_code is None
|
||||
assert records[1].membership_key == "__membership_unknown__"
|
||||
|
||||
|
||||
def test_stock_fact_normalization_preserves_all_missing_and_zero_states() -> None:
|
||||
codes = tuple(f"00000{index}.SZ" for index in range(1, 9))
|
||||
basics = tuple(
|
||||
|
||||
@@ -193,6 +193,37 @@ def test_dc_member_reloads_by_sector_when_the_all_market_call_hits_limit(
|
||||
]
|
||||
|
||||
|
||||
def test_dc_member_preserves_an_explicit_empty_partition() -> None:
|
||||
client = QueryClient(
|
||||
{
|
||||
(
|
||||
"dc_member",
|
||||
"",
|
||||
): (
|
||||
{
|
||||
"trade_date": "20260828",
|
||||
"ts_code": "BK0001.DC",
|
||||
"con_code": "000001.SZ",
|
||||
"name": "A",
|
||||
},
|
||||
),
|
||||
("dc_member", "BK0002.DC"): (),
|
||||
}
|
||||
)
|
||||
|
||||
result = make_adapter(client).fetch_sector_members(
|
||||
TARGET_DATE,
|
||||
("BK0001.DC", "BK0002.DC"),
|
||||
)
|
||||
|
||||
assert [row.sector_code for row in result.rows] == ["BK0001.DC"]
|
||||
assert [snapshot.partition_key for snapshot in result.snapshots] == [
|
||||
"all",
|
||||
"BK0002.DC",
|
||||
]
|
||||
assert result.snapshots[1].row_count == 0
|
||||
|
||||
|
||||
def test_stock_basic_explicitly_requests_all_lifecycle_statuses() -> None:
|
||||
responses = {
|
||||
(
|
||||
@@ -240,6 +271,41 @@ def test_source_snapshot_hash_is_order_stable_and_excludes_token_params() -> Non
|
||||
assert "secret" not in repr(first)
|
||||
|
||||
|
||||
def test_source_snapshot_identity_includes_schema_and_limit_metadata() -> None:
|
||||
first = build_source_snapshot(
|
||||
api_name="daily",
|
||||
params={"trade_date": "20260828"},
|
||||
rows=({"ts_code": "000001.SZ"},),
|
||||
target_trade_date=TARGET_DATE,
|
||||
observed_at=OBSERVED_AT,
|
||||
returned_fields=("ts_code",),
|
||||
row_limit=1,
|
||||
)
|
||||
changed_schema = build_source_snapshot(
|
||||
api_name="daily",
|
||||
params={"trade_date": "20260828"},
|
||||
rows=({"ts_code": "000001.SZ"},),
|
||||
target_trade_date=TARGET_DATE,
|
||||
observed_at=OBSERVED_AT,
|
||||
returned_fields=("name", "ts_code"),
|
||||
row_limit=1,
|
||||
)
|
||||
changed_limit = build_source_snapshot(
|
||||
api_name="daily",
|
||||
params={"trade_date": "20260828"},
|
||||
rows=({"ts_code": "000001.SZ"},),
|
||||
target_trade_date=TARGET_DATE,
|
||||
observed_at=OBSERVED_AT,
|
||||
returned_fields=("ts_code",),
|
||||
row_limit=2,
|
||||
)
|
||||
|
||||
assert first.content_sha256 != changed_schema.content_sha256
|
||||
assert first.snapshot_id != changed_schema.snapshot_id
|
||||
assert first.content_sha256 != changed_limit.content_sha256
|
||||
assert first.snapshot_id != changed_limit.snapshot_id
|
||||
|
||||
|
||||
def test_capability_probe_classifies_errors_without_exposing_provider_text() -> None:
|
||||
client = QueryClient({("daily", ""): RuntimeError("权限不足 private-detail")})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user