163 lines
6.9 KiB
Python
163 lines
6.9 KiB
Python
"""取消语义的 MySQL 集成测试(文档 §6.4)。
|
||
|
||
覆盖三条真实链路:
|
||
1. 取消 `queued` 运行 → `202`,`agent_run.status=cancel_requested`,
|
||
且原请求在 `request_idempotency` 落 `failed + RUN_CANCELLED`;
|
||
2. **重复取消**(换幂等键、真实 HTTP)→ 返回同一状态,**不报错**;
|
||
3. 已成功 / 已失败的运行取消 → `409 RUN_NOT_CANCELLABLE`。
|
||
|
||
依赖真实 MySQL;所有写入在 finally 中按 session_id 清理。
|
||
"""
|
||
|
||
from uuid import uuid4
|
||
|
||
import httpx
|
||
import pytest
|
||
from sqlalchemy import delete, select
|
||
|
||
from app.api.dependencies.auth import build_request_context
|
||
from app.core.contracts import AgentRequest, RequestContext
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.main import create_app
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.conversation import ConversationMessage
|
||
from app.model.platform import AgentRun, DomainEventOutbox, OutboxDelivery, RequestIdempotency
|
||
from app.service.agent_run_application_service import AgentRunApplicationService
|
||
from app.worker.runtime import WorkerRuntime
|
||
|
||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("acceptance_registry")]
|
||
|
||
|
||
async def state_of(run_id: str) -> tuple[str, str | None, str]:
|
||
async with SessionFactory() as session:
|
||
run = await session.scalar(select(AgentRun).where(AgentRun.run_id == run_id))
|
||
assert run is not None
|
||
idem = await session.get(RequestIdempotency, run.idempotency_id)
|
||
assert idem is not None
|
||
return run.status, idem.error_code, idem.status
|
||
|
||
|
||
async def cleanup(session_id: str, run_id: str) -> None:
|
||
async with SessionFactory() as session, session.begin():
|
||
event_ids = select(DomainEventOutbox.event_id).where(
|
||
DomainEventOutbox.aggregate_id == run_id)
|
||
await session.execute(delete(OutboxDelivery).where(OutboxDelivery.event_id.in_(event_ids)))
|
||
await session.execute(delete(DomainEventOutbox).where(
|
||
DomainEventOutbox.aggregate_id == run_id))
|
||
await session.execute(delete(InteractionAudit).where(
|
||
InteractionAudit.session_id == session_id))
|
||
await session.execute(delete(AgentRun).where(AgentRun.session_id == session_id))
|
||
await session.execute(delete(RequestIdempotency).where(
|
||
RequestIdempotency.session_id == session_id))
|
||
await session.execute(delete(ConversationMessage).where(
|
||
ConversationMessage.session_id == session_id))
|
||
|
||
|
||
def context() -> RequestContext:
|
||
return RequestContext(user_id="1", trace_id=str(uuid4()), roles=("customer",),
|
||
permissions=("agent:run", "agent:cancel"))
|
||
|
||
|
||
def client_for(ctx: RequestContext) -> httpx.AsyncClient:
|
||
application = create_app()
|
||
application.dependency_overrides[build_request_context] = lambda: ctx
|
||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=application),
|
||
base_url="http://test")
|
||
|
||
|
||
async def submit(ctx: RequestContext, session_id: str) -> str:
|
||
async with SessionFactory() as session:
|
||
accepted = await AgentRunApplicationService(session).accept(
|
||
AgentRequest(agent_type="customer_service", message="cancel integration",
|
||
session_id=session_id, idempotency_key=str(uuid4())),
|
||
ctx,
|
||
)
|
||
return accepted.run_id
|
||
|
||
|
||
async def test_cancel_is_idempotent_and_terminates_original_request() -> None:
|
||
ctx = context()
|
||
session_id = f"cancel-{uuid4()}"
|
||
run_id = ""
|
||
try:
|
||
run_id = await submit(ctx, session_id)
|
||
async with client_for(ctx) as client:
|
||
first = await client.post(f"/api/v1/agent-runs/{run_id}/cancellations",
|
||
json={"reason": "user_cancelled"},
|
||
headers={"Idempotency-Key": str(uuid4())})
|
||
# 换一个幂等键重复取消:必须幂等返回同一状态,而不是 409。
|
||
second = await client.post(f"/api/v1/agent-runs/{run_id}/cancellations",
|
||
json={"reason": "user_cancelled"},
|
||
headers={"Idempotency-Key": str(uuid4())})
|
||
|
||
assert first.status_code == 202
|
||
assert second.status_code == 202
|
||
assert first.json()["data"] == second.json()["data"]
|
||
assert first.json()["data"]["status"] == "cancel_requested"
|
||
assert first.json()["data"]["run_id"] == run_id
|
||
|
||
status, error_code, idem_status = await state_of(run_id)
|
||
assert status == "cancel_requested"
|
||
# 文档 §6.4:原请求以 failed + RUN_CANCELLED 结束,不扩展状态枚举。
|
||
assert (idem_status, error_code) == ("failed", "RUN_CANCELLED")
|
||
finally:
|
||
if run_id:
|
||
await cleanup(session_id, run_id)
|
||
|
||
|
||
async def test_cancel_after_worker_completion_is_not_cancellable(
|
||
acceptance_registry,
|
||
) -> None:
|
||
ctx = context()
|
||
session_id = f"cancel-done-{uuid4()}"
|
||
run_id = ""
|
||
runtime = WorkerRuntime(
|
||
acceptance_registry, resolve_identity=lambda value: _identity(value, ctx)
|
||
)
|
||
try:
|
||
run_id = await submit(ctx, session_id)
|
||
await runtime.execute(run_id)
|
||
status, _error_code, _idem = await state_of(run_id)
|
||
assert status == "succeeded"
|
||
|
||
async with client_for(ctx) as client:
|
||
response = await client.post(f"/api/v1/agent-runs/{run_id}/cancellations",
|
||
json={"reason": "user_cancelled"},
|
||
headers={"Idempotency-Key": str(uuid4())})
|
||
|
||
assert response.status_code == 409
|
||
assert response.json()["error"]["code"] == "RUN_NOT_CANCELLABLE"
|
||
assert response.json()["error"]["retryable"] is False
|
||
finally:
|
||
if run_id:
|
||
await cleanup(session_id, run_id)
|
||
|
||
|
||
async def test_worker_advances_cancel_requested_to_cancelled_with_idempotency_marker() -> None:
|
||
"""worker 真正落 `cancelled` 时,幂等记录仍必须是 `failed + RUN_CANCELLED`。"""
|
||
ctx = context()
|
||
session_id = f"cancel-worker-{uuid4()}"
|
||
run_id = ""
|
||
try:
|
||
run_id = await submit(ctx, session_id)
|
||
async with client_for(ctx) as client:
|
||
response = await client.post(f"/api/v1/agent-runs/{run_id}/cancellations",
|
||
json={"reason": "user_cancelled"},
|
||
headers={"Idempotency-Key": str(uuid4())})
|
||
assert response.status_code == 202
|
||
|
||
runtime = WorkerRuntime(resolve_identity=lambda value: _identity(value, ctx))
|
||
await runtime.execute(run_id)
|
||
|
||
status, error_code, idem_status = await state_of(run_id)
|
||
assert status == "cancelled"
|
||
assert (idem_status, error_code) == ("failed", "RUN_CANCELLED")
|
||
finally:
|
||
if run_id:
|
||
await cleanup(session_id, run_id)
|
||
|
||
|
||
async def _identity(value: RequestContext, fallback: RequestContext) -> RequestContext:
|
||
del value
|
||
return fallback
|