187 lines
7.5 KiB
Python
187 lines
7.5 KiB
Python
"""C1 回归:转人工申请只能有一条路由,且必须真正写入 Outbox 事件。
|
|||
|
|
|
||
|
|
历史上 `conversations.py` 与 `public_platform.py` 各注册了一份
|
||
|
|
`POST /api/v1/conversations/{session_id}/handover-requests`,前者先注册生效且
|
||
|
|
只写审计,导致转人工的异步链路(Outbox → Worker)永不触发。本文件用真实 HTTP
|
||
|
|
请求 + 真实 MySQL 断言唯一路由与事件落库。
|
||
|
|
"""
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
from collections.abc import Iterator
|
||
|
|
from datetime import UTC, datetime
|
||
|
|
from typing import Any
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from sqlalchemy import delete, select, text
|
||
|
|
|
||
|
|
from app.api.dependencies.auth import build_request_context
|
||
|
|
from app.core.contracts import RequestContext
|
||
|
|
from app.infrastructure.db import SessionFactory
|
||
|
|
from app.main import app
|
||
|
|
from app.model.audit import InteractionAudit
|
||
|
|
from app.model.conversation import ConversationMessage
|
||
|
|
from app.model.platform import DomainEventOutbox, HandoverTicket
|
||
|
|
from app.model.session import ConversationSession
|
||
|
|
|
||
|
|
HANDOVER_PATH = "/api/v1/conversations/{session_id}/handover-requests"
|
||
|
|
TEST_USER_ID = 9001 # 与 tools/seed_test_rbac.py 的测试客户号段一致
|
||
|
|
|
||
|
|
|
||
|
|
def _flatten(routes: Any) -> Iterator[Any]:
|
||
|
|
"""展开 include_router 的结果。
|
||
|
|
|
||
|
|
当前 FastAPI 版本把 include_router 的记录保留为 `_IncludedRouter` 而不摊平
|
||
|
|
到 `app.routes`,查重必须先递归展开到叶子路由。
|
||
|
|
"""
|
||
|
|
for route in routes:
|
||
|
|
inner = getattr(route, "original_router", None)
|
||
|
|
if inner is not None:
|
||
|
|
yield from _flatten(inner.routes)
|
||
|
|
else:
|
||
|
|
yield route
|
||
|
|
|
||
|
|
|
||
|
|
def test_handover_route_is_registered_exactly_once_by_writing_controller() -> None:
|
||
|
|
all_routes = list(_flatten(app.routes))
|
||
|
|
routes = [
|
||
|
|
route
|
||
|
|
for route in all_routes
|
||
|
|
if getattr(route, "path", None) == HANDOVER_PATH
|
||
|
|
and "POST" in (getattr(route, "methods", None) or set())
|
||
|
|
]
|
||
|
|
assert len(routes) == 1, [getattr(route, "path", None) for route in all_routes]
|
||
|
|
# 唯一入口必须落在写 Outbox 的那份实现上。
|
||
|
|
assert routes[0].endpoint.__module__ == "app.api.controllers.public_platform"
|
||
|
|
|
||
|
|
seen: list[tuple[str, str]] = [
|
||
|
|
(method, getattr(route, "path", ""))
|
||
|
|
for route in all_routes
|
||
|
|
for method in (getattr(route, "methods", None) or set())
|
||
|
|
]
|
||
|
|
duplicates = sorted({item for item in seen if seen.count(item) > 1})
|
||
|
|
assert duplicates == [], f"存在重复注册的路由: {duplicates}"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.integration
|
||
|
|
def test_handover_request_writes_ticket_event_and_audit() -> None:
|
||
|
|
session_id = f"it-handover-{uuid4().hex}"
|
||
|
|
key = f"it-handover-key-{uuid4().hex}"
|
||
|
|
handover_id = ""
|
||
|
|
|
||
|
|
async def prepare() -> None:
|
||
|
|
async with SessionFactory() as db:
|
||
|
|
db.add(
|
||
|
|
ConversationSession(
|
||
|
|
session_id=session_id, user_id=TEST_USER_ID, portal="api",
|
||
|
|
agent_type="customer_service", status="active",
|
||
|
|
clarification_round=0, message_count=1,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db.flush()
|
||
|
|
db.add(
|
||
|
|
ConversationMessage(
|
||
|
|
session_id=session_id, message_no=1, customer_id=TEST_USER_ID,
|
||
|
|
portal="api", role="user", content="我要转人工",
|
||
|
|
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
async def context() -> RequestContext:
|
||
|
|
return RequestContext(
|
||
|
|
user_id=str(TEST_USER_ID), trace_id=str(uuid4()),
|
||
|
|
roles=("customer",), permissions=("handover:create",),
|
||
|
|
)
|
||
|
|
|
||
|
|
app.dependency_overrides[build_request_context] = context
|
||
|
|
asyncio.run(prepare())
|
||
|
|
try:
|
||
|
|
with TestClient(app) as client:
|
||
|
|
response = client.post(
|
||
|
|
HANDOVER_PATH.format(session_id=session_id),
|
||
|
|
json={"reason_code": "user_requested", "reason_detail": "集成测试转人工"},
|
||
|
|
headers={"Idempotency-Key": key},
|
||
|
|
)
|
||
|
|
assert response.status_code == 202, response.text
|
||
|
|
body = response.json()
|
||
|
|
handover_id = body["data"]["handover_id"]
|
||
|
|
assert handover_id.startswith("ticket-")
|
||
|
|
assert body["data"]["session_id"] == session_id
|
||
|
|
assert body["data"]["status"] == "pending"
|
||
|
|
|
||
|
|
async def verify() -> tuple[HandoverTicket | None, DomainEventOutbox | None,
|
||
|
|
list[InteractionAudit], list[InteractionAudit]]:
|
||
|
|
async with SessionFactory() as db:
|
||
|
|
ticket = await db.scalar(
|
||
|
|
select(HandoverTicket).where(HandoverTicket.ticket_no == handover_id)
|
||
|
|
)
|
||
|
|
event = await db.scalar(
|
||
|
|
select(DomainEventOutbox).where(
|
||
|
|
DomainEventOutbox.event_type == "conversation.transfer_requested",
|
||
|
|
DomainEventOutbox.aggregate_id == session_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
audits = list(
|
||
|
|
await db.scalars(
|
||
|
|
select(InteractionAudit).where(
|
||
|
|
InteractionAudit.action_type == "platform.handover",
|
||
|
|
InteractionAudit.session_id == session_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
legacy = list(
|
||
|
|
await db.scalars(
|
||
|
|
select(InteractionAudit).where(
|
||
|
|
InteractionAudit.action_type == "conversation.transfer_requested",
|
||
|
|
InteractionAudit.session_id == session_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
return ticket, event, audits, legacy
|
||
|
|
|
||
|
|
ticket, event, audits, legacy = asyncio.run(verify())
|
||
|
|
assert ticket is not None and ticket.status == "pending"
|
||
|
|
assert ticket.source_message_id is not None # 取自会话首条消息
|
||
|
|
assert event is not None, "转人工必须写 Outbox 事件"
|
||
|
|
assert event.aggregate_type == "conversation"
|
||
|
|
assert event.status == "pending"
|
||
|
|
assert event.payload["ticket_no"] == handover_id
|
||
|
|
assert len(audits) == 1
|
||
|
|
assert legacy == [], "旧实现(只写审计、不发事件)不得再被调用"
|
||
|
|
finally:
|
||
|
|
async def cleanup() -> None:
|
||
|
|
async with SessionFactory() as db:
|
||
|
|
await db.execute(
|
||
|
|
delete(HandoverTicket).where(HandoverTicket.session_id == session_id)
|
||
|
|
)
|
||
|
|
await db.execute(
|
||
|
|
delete(DomainEventOutbox).where(
|
||
|
|
DomainEventOutbox.aggregate_id == session_id
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db.execute(
|
||
|
|
delete(InteractionAudit).where(
|
||
|
|
InteractionAudit.session_id == session_id
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db.execute(
|
||
|
|
delete(ConversationMessage).where(
|
||
|
|
ConversationMessage.session_id == session_id
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db.execute(
|
||
|
|
delete(ConversationSession).where(
|
||
|
|
ConversationSession.session_id == session_id
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db.execute(
|
||
|
|
text("DELETE FROM api_request_receipt WHERE idempotency_key = :key"),
|
||
|
|
{"key": key},
|
||
|
|
)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
asyncio.run(cleanup())
|
||
|
|
app.dependency_overrides.clear()
|