Files
group_fqcd_jr/tests/unit/service/test_run_cancellation.py
张胜宇 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

145 lines
5.0 KiB
Python
Raw Permalink 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.
"""运行取消路径的错误码契约测试(不连数据库)。
`POST /api/v1/agent-runs/{run_id}/cancellations` 的语义来自 `docs/05-接口文档.md` §6.4:
- 运行不存在或不属于当前用户 → `404 RUN_NOT_FOUND`(不泄露运行是否存在);
- 已成功、已失败或进入最终提交事务 → `409 RUN_NOT_CANCELLABLE`;
- **重复取消返回同一状态**:`cancel_requested` 与 `cancelled` 都必须幂等返回当前快照,
不得把重复取消当成错误(`RUN_CANCELLED` 不是 HTTP 响应码,见下方幂等断言);
- 取消成功后 `request_idempotency` 落 `failed + RUN_CANCELLED`。
测试手法与 `test_public_platform_service.py` 一致:替换权限闸门与事务服务,
直接驱动 `_cancel`,因此既不需要数据库,也不需要真实 repository。
"""
from datetime import UTC, datetime
from typing import Any
import pytest
from app.core.errors import RunNotCancellableError, RunNotFoundError
NOW = datetime.now(UTC).replace(tzinfo=None)
class FakeRun:
def __init__(self, status: str) -> None:
self.run_id = "run-1"
self.user_id = 9001
self.status = status
self.cancel_requested_at: datetime | None = None
self.session_id = "session-1"
self.idempotency_id = 7
class FakeSession:
"""只记录 `execute` 的绑定参数,用来断言 `request_idempotency` 的落库取值。"""
def __init__(self, run: FakeRun | None) -> None:
self._run = run
self.updates: list[dict[str, Any]] = []
async def scalar(self, _query: Any) -> FakeRun | None:
return self._run
async def execute(self, statement: Any) -> None:
self.updates.append(dict(statement.compile().params))
def service() -> Any:
from app.service.public_platform_service import PublicPlatformService
return PublicPlatformService.__new__(PublicPlatformService)
async def cancel(run: FakeRun | None) -> tuple[tuple[dict[str, Any], str], FakeSession]:
session = FakeSession(run)
result = await service()._cancel(session, "run-1", 9001, NOW)
return result, session
def idempotency_writes(session: FakeSession) -> list[dict[str, Any]]:
return [
update for update in session.updates
if update.get("status") == "failed" and "error_code" in update
]
async def test_missing_or_foreign_run_is_run_not_found() -> None:
"""越权与不存在必须返回同一码,否则可以据状态码探测运行是否存在。"""
with pytest.raises(RunNotFoundError) as excinfo:
await cancel(None)
assert excinfo.value.code == "RUN_NOT_FOUND"
assert excinfo.value.status_code == 404
@pytest.mark.parametrize("status", ["succeeded", "failed"])
async def test_terminal_run_is_not_cancellable(status: str) -> None:
with pytest.raises(RunNotCancellableError) as excinfo:
await cancel(FakeRun(status))
assert excinfo.value.code == "RUN_NOT_CANCELLABLE"
assert excinfo.value.status_code == 409
@pytest.mark.parametrize(
("status", "expected_status"),
[("cancel_requested", "cancel_requested"), ("cancelled", "cancelled")],
)
async def test_repeated_cancel_is_idempotent_and_never_an_error(
status: str, expected_status: str
) -> None:
"""重复取消必须返回同一状态,不得以 `409 RUN_CANCELLED` 之类的错误回应。"""
run = FakeRun(status)
run.cancel_requested_at = NOW
(payload, session_id), session = await cancel(run)
assert payload == {
"run_id": "run-1",
"status": expected_status,
"cancel_requested_at": NOW.isoformat() + "Z",
}
assert session_id == "session-1"
# 重复取消不改写幂等记录,也不产生新的状态迁移。
assert idempotency_writes(session) == []
async def test_queued_run_is_marked_cancel_requested_and_terminates_original_request() -> None:
run = FakeRun("queued")
(payload, session_id), session = await cancel(run)
assert run.status == "cancel_requested"
assert run.cancel_requested_at == NOW
assert payload["run_id"] == "run-1"
assert payload["status"] == "cancel_requested"
assert session_id == "session-1"
# 文档 §6.4:原请求以 failed + RUN_CANCELLED 结束,不扩展状态枚举。
writes = idempotency_writes(session)
assert writes == [{"id_1": 7, "status": "failed", "error_code": "RUN_CANCELLED",
"updated_at": NOW}]
async def test_running_run_is_cancellable_too() -> None:
run = FakeRun("running")
(payload, _session_id), session = await cancel(run)
assert payload["status"] == "cancel_requested"
assert len(idempotency_writes(session)) == 1
async def test_cancelled_run_never_reports_run_cancelled_as_http_error() -> None:
"""`RUN_CANCELLED` 是 request_idempotency 的状态标识,不是客户端错误码。"""
from app.core import errors
assert not hasattr(errors, "RunCancelledError")
run = FakeRun("cancelled")
run.cancel_requested_at = NOW
payload, _session_id = (await cancel(run))[0]
assert payload["status"] == "cancelled"