Files
XingHuo/tests/test_chat.py
T

325 lines
13 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.
"""T-06 chat 最小闭环(会话/归属/SessionGuard/窗口/落盘)。
走 main app 真路由栈(audit+trace 中间件);仓储注入 sqlite(含
agent_session/agent_message);Redis 注入 FakeRedis(窗口读写可断言,
异常路径验证降级);agent_service 走无 key 降级路径(不依赖外网)。
"""
from __future__ import annotations
import json
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from _ddl import create_sqlite_engine
from app.api import audit_middleware as audit_mod
from app.api import chat as chat_mod
from app.api import deps as deps_mod
from app.api import risk as risk_api
from app.main import app
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.repository.session_repository import SessionRepository
from app.service import memory_service
from app.service import tool_service
from app.service.auth_service import issue_dev_token as _issue # noqa: F401
from app.service import auth_service
from app.service.risk import redis_gateway
class FakeRedis:
"""窗口语义:rpush/lrange/ltrim/expire 最小实现 + 可注入故障。"""
def __init__(self):
self.lists: dict[str, list[str]] = {}
self.ttls: dict[str, int] = {}
self.fail = False
def _maybe_fail(self):
if self.fail:
raise ConnectionError("redis down")
def rpush(self, key, *vals):
self._maybe_fail()
self.lists.setdefault(key, []).extend(vals)
def lrange(self, key, start, end):
self._maybe_fail()
lst = self.lists.get(key, [])
return lst[start:] if end == -1 else lst[start : end + 1]
def ltrim(self, key, start, end):
lst = self.lists.get(key, [])
self.lists[key] = lst[start:] if end == -1 else lst[start : end + 1]
def expire(self, key, ttl):
self._maybe_fail()
self.ttls[key] = ttl
def publish(self, *a, **k):
pass
def delete(self, *a, **k):
pass
def exists(self, key):
return False
def set_ex(self, *a, **k):
pass
CUSTOMER = {"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-9527", "X-Agent-Type": "customer"}
ADVISOR = {"X-Debug-Role": "advisor", "X-Debug-Actor": "STAFF-10086", "X-Agent-Type": "advisor"}
@pytest.fixture()
def env(monkeypatch):
engine = create_sqlite_engine()
repo = RiskRepository(engine=engine)
session_repo = SessionRepository(engine=engine)
core_ro = CoreReadOnlyRepository(engine=engine)
with engine.begin() as conn: # 归属种子(rbac-seed-reference 口径)
conn.execute(
text("INSERT INTO core_customer_advisor (advisor_id, customer_id, rel_status)"
" VALUES ('STAFF-10086', 'CUST-9527', 'active'),"
" ('STAFF-10087', 'CUST-1010', 'active')")
)
fake_redis = FakeRedis()
monkeypatch.setattr(chat_mod, "_repo", lambda: repo)
monkeypatch.setattr(chat_mod, "_session_repo", lambda: session_repo)
monkeypatch.setattr(chat_mod, "_core_ro", lambda: core_ro)
monkeypatch.setattr(memory_service, "_session_repo", lambda: session_repo)
# T-04:Tool 节点经 tool_service 落库/查库,同一 sqlite 注入
monkeypatch.setattr(tool_service, "_session_repo", lambda: session_repo)
monkeypatch.setattr(tool_service, "_core_ro", lambda: core_ro)
# T-04 复审:越权双写共用同一 sqlite 仓储——漏打桩会写脏本机演示 MySQL
monkeypatch.setattr(tool_service, "_risk_repo", lambda: repo)
monkeypatch.setattr(risk_api, "_repo", lambda: repo)
monkeypatch.setattr(audit_mod, "_repo", lambda: repo)
monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo)
monkeypatch.setattr(redis_gateway, "_gateway", fake_redis)
yield {"client": TestClient(app), "repo": repo, "engine": engine, "redis": fake_redis}
engine.dispose()
def _rows(engine, sql, **params):
with engine.connect() as conn:
return [dict(r) for r in conn.execute(text(sql), params).mappings().all()]
def test_chat_new_session_creates_session_and_messages(env):
r = env["client"].post("/api/chat", json={"message": "查一下我的持仓"}, headers=CUSTOMER)
assert r.status_code == 200
body = r.json()
assert body["session_id"].startswith("sess-")
assert body["agent_type"] == "customer"
assert body["customer_id"] == "CUST-9527"
assert body["has_disclaimer"] is True # 客户对外口径(降级回复同样经 guard)
assert body["trace_id"] == r.headers["X-Trace-Id"]
sessions = _rows(env["engine"], "SELECT * FROM agent_session")
assert len(sessions) == 1
s = sessions[0]
assert (s["actor_id"], s["agent_type"], s["actor_role"], s["customer_id"]) == (
"CUST-9527", "customer", "customer", "CUST-9527"
)
assert s["trace_id"] == body["trace_id"] # 手册 §9:会话绑定 trace_id
msgs = _rows(
env["engine"],
"SELECT role, has_disclaimer FROM agent_message ORDER BY seq_no",
)
assert [(m["role"], m["has_disclaimer"]) for m in msgs] == [("user", 0), ("assistant", 1)]
def test_chat_resume_appends_messages(env):
sid = env["client"].post("/api/chat", json={"message": "第一句"}, headers=CUSTOMER).json()["session_id"]
r = env["client"].post("/api/chat", json={"message": "第二句", "session_id": sid}, headers=CUSTOMER)
assert r.status_code == 200 and r.json()["session_id"] == sid
msgs = _rows(env["engine"], "SELECT seq_no, role FROM agent_message ORDER BY seq_no")
assert [m["role"] for m in msgs] == ["user", "assistant", "user", "assistant"]
assert [m["seq_no"] for m in msgs] == [1, 2, 3, 4]
def test_chat_redis_window_written(env):
r = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER)
sid = r.json()["session_id"]
key = f"sess:customer:{sid}:msgs"
items = [json.loads(x) for x in env["redis"].lists[key]]
assert [(m["role"], m["content"]) for m in items] == [("user", "hi"), ("assistant", items[1]["content"])]
assert env["redis"].ttls[key] == 2 * 3600
def test_chat_redis_down_degrades(env, monkeypatch):
"""Redis 异常降级:窗口读写失败不阻塞对话(MySQL 为权威)。"""
env["redis"].fail = True
r = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER)
assert r.status_code == 200
def test_chat_session_of_other_actor_denied(env):
sid = env["client"].post("/api/chat", json={"message": "第一句"}, headers=CUSTOMER).json()["session_id"]
other = {**CUSTOMER, "X-Debug-Actor": "CUST-1001"}
r = env["client"].post("/api/chat", json={"message": "续聊", "session_id": sid}, headers=other)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_SESSION_AGENT"
assert _rows(env["engine"], "SELECT 1 FROM audit_log WHERE decision = 'forbidden'")
def test_chat_session_of_other_agent_type_denied(env):
sid = env["client"].post("/api/chat", json={"message": "第一句"}, headers=CUSTOMER).json()["session_id"]
r = env["client"].post(
"/api/chat",
json={"message": "续聊", "session_id": sid},
headers={**ADVISOR, "X-Agent-Type": "advisor"},
)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_SESSION_AGENT"
def test_chat_customer_forged_customer_id_denied(env):
r = env["client"].post(
"/api/chat", json={"message": "hi", "customer_id": "CUST-1001"}, headers=CUSTOMER
)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_NOT_OWNER"
def test_chat_advisor_assigned_customer_ok(env):
r = env["client"].post(
"/api/chat", json={"message": "客户情况如何", "customer_id": "CUST-9527"}, headers=ADVISOR
)
assert r.status_code == 200
body = r.json()
assert body["customer_id"] == "CUST-9527"
assert body["has_disclaimer"] is False # 内部角色无免责声明
def test_chat_advisor_not_assigned_denied(env):
r = env["client"].post(
"/api/chat", json={"message": "hi", "customer_id": "CUST-1010"}, headers=ADVISOR
)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_NOT_ASSIGNED"
def test_chat_agent_type_missing_or_invalid(env):
r = env["client"].post(
"/api/chat", json={"message": "hi"}, headers={"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-9527"}
)
assert r.status_code == 401 and r.json()["error_code"] == "AUTH_401_MISSING_AGENT_TYPE"
r2 = env["client"].post("/api/chat", json={"message": "hi"}, headers={**CUSTOMER, "X-Agent-Type": "ops"})
assert r2.status_code == 400
def test_chat_agent_mismatch_debug_channel(env):
"""手册 §5.4:advisor 角色不能进 customer agent(debug 通道同样强制)。"""
r = env["client"].post(
"/api/chat", json={"message": "hi"}, headers={**ADVISOR, "X-Agent-Type": "customer"}
)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_AGENT_MISMATCH"
def test_chat_session_not_found_and_closed(env):
r = env["client"].post("/api/chat", json={"message": "hi", "session_id": "sess-nope"}, headers=CUSTOMER)
assert r.status_code == 404
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
with env["engine"].begin() as conn:
conn.execute(text("UPDATE agent_session SET status = 'closed' WHERE session_id = :s"), {"s": sid})
r2 = env["client"].post("/api/chat", json={"message": "hi", "session_id": sid}, headers=CUSTOMER)
assert r2.status_code == 409 and r2.json()["error_code"] == "STATE_CONFLICT"
# ---------- T-04 Tool 全链路(经真路由栈触发 Tool 节点) ----------
def test_chat_tool_holdings_full_link(env):
"""持仓关键词触发 Tool:agent_tool_call 落库(trace/session 贯通)+ 降级回复带摘要。"""
r = env["client"].post("/api/chat", json={"message": "查一下我的持仓"}, headers=CUSTOMER)
assert r.status_code == 200
body = r.json()
rows = _rows(env["engine"], "SELECT * FROM agent_tool_call")
assert len(rows) == 1
row = rows[0]
assert (row["session_id"], row["trace_id"], row["tool_name"], row["status"]) == (
body["session_id"], body["trace_id"], "query_holdings", "success"
)
assert "LLM 未配置" in body["reply"] # env 无 key 走降级
assert "持仓查询" in body["reply"] # 降级回复携带 Tool 摘要(env 无持仓种子 → 0 笔)
def test_chat_tool_no_intent_no_call(env):
r = env["client"].post("/api/chat", json={"message": "今天天气如何"}, headers=CUSTOMER)
assert r.status_code == 200
assert _rows(env["engine"], "SELECT 1 FROM agent_tool_call") == []
def test_chat_tool_advisor_profile_query(env):
"""advisor(已分配)测评关键词 → L0 查询 success(A-01 归属链路)。"""
with env["engine"].begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, age, is_active)"
" VALUES ('CUST-9527', '张三', 45, 1)"
)
)
conn.execute(
text(
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at)"
" VALUES ('CUST-9527', 'B', '2026-08-01 10:00:00')"
)
)
r = env["client"].post(
"/api/chat", json={"message": "客户的风险测评等级", "customer_id": "CUST-9527"}, headers=ADVISOR
)
assert r.status_code == 200
rows = _rows(env["engine"], "SELECT tool_name, status FROM agent_tool_call")
assert [(x["tool_name"], x["status"]) for x in rows] == [("query_customer_profile", "success")]
def test_chat_tool_risk_agent_no_intent_rule(env):
"""risk 分支无意图规则(C1/C2 接风控 Tool),不触发 Core RO 查询。"""
r = env["client"].post(
"/api/chat",
json={"message": "查一下我的持仓", "customer_id": "CUST-9527"},
headers={"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001", "X-Agent-Type": "risk"},
)
assert r.status_code == 200
assert _rows(env["engine"], "SELECT 1 FROM agent_tool_call") == []
# ---------- JWT 通道(生产主链路 · 评审 P2-3) ----------
def test_chat_jwt_customer_new_session(env):
tok = auth_service.issue_dev_token(
sub="CUST-9527", roles=["customer"], token_type="customer", customer_id="CUST-9527"
)
r = env["client"].post(
"/api/chat", json={"message": "查持仓"},
headers={"Authorization": f"Bearer {tok}", "X-Agent-Type": "customer"},
)
assert r.status_code == 200
body = r.json()
assert body["customer_id"] == "CUST-9527" and body["has_disclaimer"] is True
s = _rows(env["engine"], "SELECT actor_id, actor_role FROM agent_session")[0]
assert (s["actor_id"], s["actor_role"]) == ("CUST-9527", "customer")
def test_chat_jwt_advisor_not_assigned_denied(env):
tok = auth_service.issue_dev_token(sub="STAFF-10086", roles=["advisor"])
r = env["client"].post(
"/api/chat", json={"message": "hi", "customer_id": "CUST-1010"},
headers={"Authorization": f"Bearer {tok}", "X-Agent-Type": "advisor"},
)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_NOT_ASSIGNED"
def test_chat_jwt_agent_mismatch_denied(env):
tok = auth_service.issue_dev_token(sub="STAFF-30001", roles=["risk_officer"])
r = env["client"].post(
"/api/chat", json={"message": "hi"},
headers={"Authorization": f"Bearer {tok}", "X-Agent-Type": "customer"},
)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_AGENT_MISMATCH"