"""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()