Files
group_fqcd_jr/tests/unit/service/test_risk_query_service.py
T

208 lines
7.4 KiB
Python
Raw Normal View History

from datetime import datetime
from decimal import Decimal
from types import MappingProxyType
import pytest
from app.api.schemas.risk import RiskAlertPageQuery, RiskEvidencePageQuery
from app.core.contracts import RequestContext
from app.core.errors import GenericResourceNotFoundError, InvalidCursorError
from app.core.risk_cursor import cursor_binding, decode_offset_cursor, encode_offset_cursor
from app.repository.fund_query_repository import CustomerScope, FundPage, FundRecord
from app.service.risk_query_service import RiskQueryService, scope_from_context
class FakeRepository:
def __init__(self) -> None:
self.page = FundPage(
entity="risk_alert",
items=(
FundRecord(
entity="risk_alert",
values=MappingProxyType(
{
"alert_no": "ALERT-001",
"customer_id": 9,
"risk_level": "高",
"amount": Decimal("100.00"),
"created_at": datetime(2026, 9, 10, 8, 0),
"rule_codes": ("RW-007",),
}
),
),
),
limit=5,
offset=0,
next_offset=5,
)
async def overview(self) -> dict:
return {
"total": 2,
"levels": {"低": 1, "中": 0, "高": 1},
"pending": 1,
"overdue": 0,
"high_priority": list(self.page.items),
}
async def list_alerts(self, **_kwargs) -> FundPage:
return self.page
async def get_alert_detail(self, _alert_no: str) -> FundRecord | None:
return None
class RecordingRepository(FakeRepository):
def __init__(self) -> None:
super().__init__()
self.calls: list[tuple[str, dict]] = []
async def list_transactions(self, **kwargs) -> FundPage:
self.calls.append(("transactions", kwargs))
return self.page
async def list_capital_flows(self, **kwargs) -> FundPage:
self.calls.append(("capital_flows", kwargs))
return self.page
async def list_login_records(self, **kwargs) -> FundPage:
self.calls.append(("login_records", kwargs))
return self.page
async def list_notifications(self, **kwargs) -> FundPage:
self.calls.append(("notifications", kwargs))
return self.page
def context(**updates) -> RequestContext:
values = {
"user_id": "990000002",
"trace_id": "trace",
"permissions": ("risk:alert:read",),
"data_scope": "all",
}
values.update(updates)
return RequestContext(**values)
def test_cursor_round_trip_and_invalid_values() -> None:
binding = cursor_binding(user_id="990000002", filters={"keyword": None})
assert (
decode_offset_cursor(encode_offset_cursor(20, binding=binding), binding=binding) == 20
)
assert decode_offset_cursor(None, binding=binding) == 0
with pytest.raises(InvalidCursorError):
decode_offset_cursor("not-a-cursor", binding=binding)
with pytest.raises(InvalidCursorError):
decode_offset_cursor(encode_offset_cursor(1, binding=binding) + "x", binding=binding)
def test_query_schema_enforces_business_page_sizes() -> None:
assert RiskAlertPageQuery(limit=5).limit == 5
assert RiskEvidencePageQuery(limit=10).limit == 10
with pytest.raises(ValueError):
RiskAlertPageQuery(limit=6)
with pytest.raises(ValueError):
RiskEvidencePageQuery(limit=11)
def test_scope_from_context_is_fail_closed() -> None:
assert scope_from_context(context()).is_unrestricted
assert scope_from_context(context(data_scope="self")).is_denied
scope = scope_from_context(
context(data_scope="own_customers", customer_ids=("9", "10"))
)
assert scope == CustomerScope.for_customers({9, 10})
@pytest.mark.asyncio
async def test_overview_maps_levels_and_serializes_high_priority() -> None:
service = RiskQueryService(None, repository=FakeRepository())
result = await service.overview(context())
assert result["levels"] == {"高风险": 1, "中风险": 0, "低风险": 1}
assert result["high_priority"][0]["customer_id"] == "9"
assert result["high_priority"][0]["amount"] == "100.00"
assert result["high_priority"][0]["created_at"] == "2026-09-10T08:00:00Z"
@pytest.mark.asyncio
async def test_alert_page_returns_opaque_cursor() -> None:
service = RiskQueryService(None, repository=FakeRepository())
query = RiskAlertPageQuery(limit=5)
result = await service.list_alerts(context(), query)
assert result["has_more"] is True
binding = RiskQueryService._binding(context(), query)
assert decode_offset_cursor(result["next_cursor"], binding=binding) == 5
assert result["items"][0]["rule_codes"] == ["RW-007"]
def test_cursor_is_bound_to_user_and_filters() -> None:
"""§3.8:游标绑定用户与查询条件,换个人或换个筛选条件都必须失效。"""
query = RiskAlertPageQuery(limit=5)
raw = encode_offset_cursor(5, binding=RiskQueryService._binding(context(), query))
assert decode_offset_cursor(raw, binding=RiskQueryService._binding(context(), query)) == 5
with pytest.raises(InvalidCursorError):
decode_offset_cursor(
raw,
binding=RiskQueryService._binding(context(user_id="990000003"), query),
)
with pytest.raises(InvalidCursorError):
other_filter = RiskAlertPageQuery(limit=5, keyword="张三")
decode_offset_cursor(
raw,
binding=RiskQueryService._binding(context(), other_filter),
)
# data_scope 收紧后旧游标同样失效
with pytest.raises(InvalidCursorError):
decode_offset_cursor(
raw,
binding=RiskQueryService._binding(
context(data_scope="own_customers", customer_ids=("9",)), query
),
)
def test_cursor_ignores_page_size_and_other_filters_do_not_collide() -> None:
"""翻页时改 limit 不该让游标失效;不同证据类型之间不能互相串用游标。"""
base = RiskEvidencePageQuery(limit=10)
binding = RiskQueryService._binding(context(), base, "customers")
again = RiskQueryService._binding(context(), RiskEvidencePageQuery(limit=10), "customers")
other = RiskQueryService._binding(context(), RiskEvidencePageQuery(limit=10), "products")
assert binding == again
assert binding != other
@pytest.mark.asyncio
async def test_missing_alert_detail_is_hidden() -> None:
service = RiskQueryService(None, repository=FakeRepository())
with pytest.raises(GenericResourceNotFoundError):
await service.get_alert_detail(context(), "ALERT-NOT-FOUND")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"source",
("transactions", "capital_flows", "login_records", "notifications"),
)
async def test_evidence_time_filters_are_interpreted_as_local_time(source: str) -> None:
repository = RecordingRepository()
service = RiskQueryService(None, repository=repository)
query = RiskEvidencePageQuery(
start_time=datetime(2026, 9, 10, 12, 0),
end_time=datetime(2026, 9, 10, 13, 0),
)
await service.list_evidence(context(), source, query)
assert repository.calls[0][0] == source
assert repository.calls[0][1]["start_time"] == datetime(2026, 9, 10, 4, 0)
assert repository.calls[0][1]["end_time"] == datetime(2026, 9, 10, 5, 0)