Files
group_fqcd_jr/tests/integration/test_risk_idempotency_mysql.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

179 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""风控写接口的幂等语义(`docs/05` §5.1、§5.2;`docs/25` P3 #22)。
两级覆盖:
1. `ApiTransactionService.execute_in` 本身 —— 同键同正文回放原响应且**不重复执行**、
同键不同正文返回 `409 IDEMPOTENCY_CONFLICT`、缺键直接拒绝;
2. Controller 接线 —— 重复 POST 同一个风控处置端点时,Action Service 只被调用一次。
刻意不去驱动真实状态机:处置动作会改预警状态,测试不该污染演示数据。这里用计数替身
验证"第二次请求没有落到业务逻辑上",这才是幂等要保证的事情。
"""
from collections.abc import AsyncIterator
from typing import Any
from uuid import uuid4
import httpx
import pytest
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.controllers import risk as risk_controller
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
from app.core.contracts import RequestContext
from app.core.errors import IdempotencyConflictError, ValidationAgentError
from app.infrastructure.db import SessionFactory
from app.main import app
from app.repository.platform_repository import PlatformRepository
from app.service.api_transaction_service import ApiTransactionService
SCOPE = "POST /api/v1/risk/alerts/ALERT-IDEM-TEST/acknowledgements"
EXCLUSION_SCOPE = "POST /api/v1/risk/alerts/ALERT-IDEM-TEST/exclusions"
ACK_PATH = "/api/v1/risk/alerts/ALERT-IDEM-TEST/acknowledgements"
EXCLUSION_PATH = "/api/v1/risk/alerts/ALERT-IDEM-TEST/exclusions"
async def override_context() -> RequestContext:
return RequestContext(
user_id="990000002",
trace_id=str(uuid4()),
roles=("risk_operator",),
permissions=("risk:alert:read", "risk:alert:write"),
data_scope="all",
)
async def override_session() -> AsyncIterator[AsyncSession]:
async with SessionFactory() as session:
yield session
async def purge(*keys: str) -> None:
async with SessionFactory() as session, session.begin():
table = await PlatformRepository(session).table("api_request_receipt")
for key in keys:
await session.execute(delete(table).where(table.c.idempotency_key == key))
@pytest.mark.integration
@pytest.mark.asyncio
async def test_execute_in_replays_without_running_action_twice() -> None:
key = f"risk-idem-{uuid4()}"
context = await override_context()
calls: list[int] = []
async def action(_session: AsyncSession) -> dict[str, Any]:
calls.append(1)
return {"alert_no": "ALERT-IDEM-TEST", "status": "待处理"}
try:
async with SessionFactory() as session:
service = ApiTransactionService()
first = await service.execute_in(session, context, SCOPE, key, {}, action)
second = await service.execute_in(session, context, SCOPE, key, {}, action)
assert first == second == {"alert_no": "ALERT-IDEM-TEST", "status": "待处理"}
assert len(calls) == 1, "重复请求必须回放 response_json,而不是再次执行 action"
finally:
await purge(key)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_execute_in_conflicts_on_same_key_with_different_body() -> None:
key = f"risk-idem-{uuid4()}"
context = await override_context()
async def action(_session: AsyncSession) -> dict[str, Any]:
return {"ok": True}
try:
async with SessionFactory() as session:
service = ApiTransactionService()
await service.execute_in(session, context, SCOPE, key, {"reason": "第一次"}, action)
with pytest.raises(IdempotencyConflictError):
await service.execute_in(
session, context, SCOPE, key, {"reason": "第二次"}, action
)
finally:
await purge(key)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_execute_in_rejects_missing_or_short_key() -> None:
context = await override_context()
async def action(_session: AsyncSession) -> dict[str, Any]:
raise AssertionError("缺键时不应执行 action")
async with SessionFactory() as session:
service = ApiTransactionService()
for bad in (None, "too-short", "带中文字符的-key-1234567890"):
with pytest.raises(ValidationAgentError):
await service.execute_in(session, context, SCOPE, bad, {}, action)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_repeated_risk_post_calls_action_service_once(monkeypatch) -> None:
"""端到端:同一 `Idempotency-Key` 重复 POST 只触发一次处置逻辑。
用 `httpx.ASGITransport` 而不是 `TestClient`:后者自建事件循环,测试结束后的
`SessionFactory` 清理会落在另一个循环上,连接池析构时报
`AttributeError: 'NoneType' object has no attribute 'send'`。
"""
calls: list[str] = []
class CountingActionService:
def __init__(self, _session: Any) -> None:
pass
async def acknowledge(self, alert_no: str, _context: RequestContext) -> dict[str, Any]:
calls.append(alert_no)
return {"alert_no": alert_no, "status": "待处理", "ack_status": "已确认"}
async def exclude(
self, alert_no: str, reason: str, _context: RequestContext
) -> dict[str, Any]:
calls.append(f"exclude:{reason}")
return {"alert_no": alert_no, "status": "已排除", "handle_result": reason}
monkeypatch.setattr(risk_controller, "RiskActionService", CountingActionService)
ack_key = f"risk-ack-{uuid4()}"
exclude_key = f"risk-exclude-{uuid4()}"
app.dependency_overrides[build_request_context] = override_context
app.dependency_overrides[get_session] = override_session
try:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
first = await client.post(ACK_PATH, headers={"Idempotency-Key": ack_key})
replay = await client.post(ACK_PATH, headers={"Idempotency-Key": ack_key})
excluded = await client.post(
EXCLUSION_PATH,
json={"reason": "客户本人确认"},
headers={"Idempotency-Key": exclude_key},
)
conflict = await client.post(
EXCLUSION_PATH,
json={"reason": "换了理由"},
headers={"Idempotency-Key": exclude_key},
)
assert first.status_code == 200
assert replay.status_code == 200
# 只比 `data`:`meta.trace_id` 标识的是**本次**请求,重放也必须换一个新的。
assert replay.json()["data"] == first.json()["data"]
assert excluded.status_code == 200
assert conflict.status_code == 409
assert conflict.json()["error"]["code"] == "IDEMPOTENCY_CONFLICT"
assert calls == ["ALERT-IDEM-TEST", "exclude:客户本人确认"]
finally:
app.dependency_overrides.pop(build_request_context, None)
app.dependency_overrides.pop(get_session, None)
await purge(ack_key, exclude_key)