208 lines
7.4 KiB
Python
208 lines
7.4 KiB
Python
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)
|