Files
group_xinghuo_jinrong/tests/test_chat.py
T
zhanghongyu_0626 7dadec279d fix(memory): Address multi-turn dialogue defects and enhance context handling
- Implemented `_merged_items` and `_merged_memory_text` functions to consolidate consult and chitchat memories, improving context awareness in intent classification and response generation.
- Updated intent prompts to include recent dialogue history, aiding in the resolution of ambiguous user queries.
- Enhanced `search_knowledge` tool to utilize context window for better query understanding, addressing issues with omitted references in user inputs.
- Fixed existing test cases to reflect changes in intent constants and ensure accurate context handling during tests.

This update significantly improves the handling of multi-turn dialogues, ensuring a more coherent and contextually aware interaction for users.
2026-09-14 00:59:14 +08:00

644 lines
28 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.config.settings import settings
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 agent_service, customer_service as cs, memory_service, tool_service
from app.service.auth_service import issue_dev_token as _issue # noqa: F401
from app.service import auth_service
from app.service.profile_service import CustomerMemoryService
from app.service.risk import redis_gateway
class FakeCSMsg:
def __init__(self, content: str) -> None:
self.content = content
class FakeCSLLM:
"""客服 LangGraph 用 LLM 替身(按 system prompt 关键词应答)。"""
def invoke(self, messages):
system = messages[0]["content"] if messages else ""
user = messages[-1]["content"] if messages else ""
if "意图分类" in system:
return FakeCSMsg("chit_chat")
if "系统查询结果" in user or "事实数据" in user:
return FakeCSMsg("您当前没有持仓记录。")
return FakeCSMsg("您好,有什么可以帮您?")
class _FakeHotCache:
def get_style_tags(self, cid):
return {}
def get_style_tags_lazy(self, cid):
return {}
def invalidate(self, cid):
pass
class _FakeVisitorRag:
def retrieve(self, intent, query, top_k=5):
return "", []
class FakeRedis:
"""窗口 + 客服记忆 Redis 替身(可注入故障)。"""
def __init__(self):
self.strings: dict[str, str] = {}
self.lists: dict[str, list[str]] = {}
self.hashes: dict[str, dict] = {}
self.ttls: dict[str, int] = {}
self.fail = False
def _maybe_fail(self):
if self.fail:
raise ConnectionError("redis down")
def get(self, key):
return self.strings.get(key)
def setex(self, key, ttl, value):
self._maybe_fail()
self.strings[key] = value
self.ttls[key] = ttl
def incr(self, key):
self._maybe_fail()
value = int(self.strings.get(key, 0)) + 1
self.strings[key] = str(value)
return value
def hincrby(self, key, field, delta=1):
h = self.hashes.setdefault(key, {})
h[field] = int(h.get(field, 0)) + delta
return h[field]
def hgetall(self, key):
return dict(self.hashes.get(key, {}))
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, *keys):
for key in keys:
self.strings.pop(key, None)
self.lists.pop(key, None)
self.hashes.pop(key, None)
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()
cs_llm = FakeCSLLM()
monkeypatch.setattr(settings, "deepseek_api_key", "")
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)
# S2 接缝:customer 分流走 customer_service(Redis/LLM/归档替身)
monkeypatch.setattr("app.config.database.get_redis_client", lambda: fake_redis)
monkeypatch.setattr(cs, "_build_llm", lambda: cs_llm)
monkeypatch.setattr(cs, "CustomerMemoryService", lambda: CustomerMemoryService(redis_client=fake_redis))
monkeypatch.setattr(cs, "ProfileHotCache", lambda repo=None: _FakeHotCache())
monkeypatch.setattr(cs, "VisitorRagService", _FakeVisitorRag)
monkeypatch.setattr(cs, "_spawn", lambda fn, *a: fn(*a))
monkeypatch.setattr(cs, "archive_session", lambda *a, **k: True)
monkeypatch.setattr(cs, "archive_idle_sessions", lambda *a, **k: [])
monkeypatch.setattr(cs, "extract_profile", lambda *a, **k: [])
monkeypatch.setattr(cs, "record_intent", lambda *a, **k: None)
monkeypatch.setattr("app.tool.core_ro_tool.CoreReadOnlyRepository", lambda: core_ro)
yield {
"client": TestClient(app),
"repo": repo,
"engine": engine,
"redis": fake_redis,
"cs_llm": cs_llm,
"core_ro": core_ro,
}
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):
"""customer 分流(S2):会话/落盘/持仓工具链;免责声明口径随 customer_service。"""
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["intent"] == "holding_query"
assert body["has_disclaimer"] is False
assert body["transfer_to_human"] is False
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", 0)]
stored = _rows(
env["engine"],
"SELECT content FROM agent_message WHERE role = 'assistant' ORDER BY seq_no LIMIT 1",
)[0]["content"]
assert body["reply"] == stored # HTTP 响应与落库正文同口径
def test_chat_sync_persist_disclaimer_when_guard_skips_append(env, monkeypatch):
"""落库在 API 层显式拼免责声明:customer 分流同样经 _assistant_content_for_persist。"""
monkeypatch.setattr(
chat_mod,
"run_customer_chat",
lambda *a, **k: ("仅正文", True, "chit_chat", False, None),
)
r = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER)
assert r.status_code == 200
expected = f"仅正文\n\n{agent_service.CHAT_DISCLAIMER}"
assert r.json()["reply"] == expected
stored = _rows(
env["engine"],
"SELECT content, has_disclaimer FROM agent_message WHERE role = 'assistant'",
)[0]
assert stored["content"] == expected and stored["has_disclaimer"] == 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):
"""持仓关键词 → customer_service Core RO 工具链(不经 agent_tool_call 表)。"""
r = env["client"].post("/api/chat", json={"message": "查一下我的持仓"}, headers=CUSTOMER)
assert r.status_code == 200
body = r.json()
assert body["intent"] == "holding_query"
assert "持仓" in body["reply"]
assert _rows(env["engine"], "SELECT 1 FROM agent_tool_call") == []
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") == []
def test_chat_risk_manager_entry_denied(env):
"""C5 前置(PRD 4A.1):对话线不放行 risk_manager——矩阵放行 HTTP 通道,
chat 层显式 deny 保住 FR-6 冻结口径;返回 403 + AUTH_403_ROLE,且不得建会话。"""
r = env["client"].post(
"/api/chat",
json={"message": "查一下预警台账", "customer_id": "CUST-9527"},
headers={"X-Debug-Role": "risk_manager", "X-Debug-Actor": "STAFF-31001", "X-Agent-Type": "risk"},
)
assert r.status_code == 403
assert r.json()["error_code"] == "AUTH_403_ROLE"
assert _rows(env["engine"], "SELECT 1 FROM agent_session") == []
def test_chat_compliance_entry_denied_on_risk_line(env):
"""F12:compliance 可经 risk 矩阵访问 HTTP aml 台账,但对话线仍仅 risk_officer。"""
r = env["client"].post(
"/api/chat",
json={"message": "你好"},
headers={"X-Debug-Role": "compliance", "X-Debug-Actor": "STAFF-40001", "X-Agent-Type": "risk"},
)
assert r.status_code == 403
assert r.json()["error_code"] == "AUTH_403_ROLE"
assert _rows(env["engine"], "SELECT 1 FROM agent_session") == []
# ---------- 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"
assert body["intent"] == "holding_query"
assert body["has_disclaimer"] is False
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"
# ---------- 方案 B:前端拉侧(会话列表 / 历史消息 / 关闭会话) ----------
RISK_OFFICER = {"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001", "X-Agent-Type": "risk"}
RISK_MANAGER = {"X-Debug-Role": "risk_manager", "X-Debug-Actor": "STAFF-31001", "X-Agent-Type": "risk"}
COMPLIANCE_RISK = {"X-Debug-Role": "compliance", "X-Debug-Actor": "STAFF-40001", "X-Agent-Type": "risk"}
def test_sessions_list_only_own_and_paged(env):
"""会话列表:仅本人 + 本 Agent 线;created_at 倒序;limit/offset 分页 + total。"""
sid1 = env["client"].post("/api/chat", json={"message": "第一条"}, headers=CUSTOMER).json()["session_id"]
sid2 = env["client"].post("/api/chat", json={"message": "第二条"}, headers=CUSTOMER).json()["session_id"]
# 另一 actor 的会话不应出现在我的列表里
env["client"].post(
"/api/chat", json={"message": "别人的"},
headers={"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1001", "X-Agent-Type": "customer"},
)
r = env["client"].get("/api/chat/sessions", headers=CUSTOMER)
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert [s["session_id"] for s in body["items"]] == [sid2, sid1] # 倒序
assert all(s["agent_type"] == "customer" and s["status"] == "active" for s in body["items"])
r2 = env["client"].get("/api/chat/sessions?limit=1&offset=1", headers=CUSTOMER)
body2 = r2.json()
assert body2["total"] == 2 and len(body2["items"]) == 1
assert body2["items"][0]["session_id"] == sid1
def test_sessions_list_risk_line_isolated(env):
"""agent_type 隔离:risk 线会话不出现在 customer 线列表(反之亦然)。"""
env["client"].post("/api/chat", json={"message": "客户线"}, headers=CUSTOMER)
r = env["client"].get("/api/chat/sessions", headers=RISK_OFFICER)
assert r.status_code == 200 and r.json()["total"] == 0
def test_messages_page_ascending_and_paged(env):
"""历史消息:seq_no 升序全量分页;has_disclaimer 透出;closed 后仍可读。"""
sid = env["client"].post("/api/chat", json={"message": "第一句"}, headers=CUSTOMER).json()["session_id"]
env["client"].post("/api/chat", json={"message": "第二句", "session_id": sid}, headers=CUSTOMER)
r = env["client"].get(f"/api/chat/sessions/{sid}/messages", headers=CUSTOMER)
assert r.status_code == 200
body = r.json()
assert body["total"] == 4
assert [m["role"] for m in body["items"]] == ["user", "assistant", "user", "assistant"]
assert [m["seq_no"] for m in body["items"]] == [1, 2, 3, 4]
assert body["items"][0]["content"] == "第一句"
assert body["items"][1]["has_disclaimer"] is False # customer 线 chitchat 默认无 guard 免责声明
r2 = env["client"].get(f"/api/chat/sessions/{sid}/messages?limit=2&offset=2", headers=CUSTOMER)
body2 = r2.json()
assert body2["total"] == 4 and [m["seq_no"] for m in body2["items"]] == [3, 4]
def test_messages_of_other_actor_denied(env):
"""SessionGuard:他人会话历史 403 + 审计留痕(与 POST 同口径)。"""
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
other = {**CUSTOMER, "X-Debug-Actor": "CUST-1001"}
r = env["client"].get(f"/api/chat/sessions/{sid}/messages", 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_messages_not_found_and_agent_type_mismatch(env):
r = env["client"].get("/api/chat/sessions/sess-nope/messages", headers=CUSTOMER)
assert r.status_code == 404
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
# 会话在 customer 线,advisor 头查 → agent_type 不一致 403
r2 = env["client"].get(f"/api/chat/sessions/{sid}/messages", headers=ADVISOR)
assert r2.status_code == 403 and r2.json()["error_code"] == "AUTH_403_SESSION_AGENT"
def test_close_session_then_read_only(env):
"""关闭会话:200 + closed_at 落库;续聊 409;重复关闭 409;历史仍可读。"""
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
r = env["client"].post(f"/api/chat/sessions/{sid}/close", headers=CUSTOMER)
assert r.status_code == 200 and r.json() == {"session_id": sid, "status": "closed"}
row = _rows(env["engine"], "SELECT status, closed_at FROM agent_session WHERE session_id = :s", s=sid)[0]
assert row["status"] == "closed" and row["closed_at"] is not None
r2 = env["client"].post("/api/chat", json={"message": "续聊", "session_id": sid}, headers=CUSTOMER)
assert r2.status_code == 409 and r2.json()["error_code"] == "STATE_CONFLICT"
r3 = env["client"].post(f"/api/chat/sessions/{sid}/close", headers=CUSTOMER)
assert r3.status_code == 409 and r3.json()["error_code"] == "STATE_CONFLICT"
r4 = env["client"].get(f"/api/chat/sessions/{sid}/messages", headers=CUSTOMER)
assert r4.status_code == 200 and r4.json()["total"] == 2
def test_close_other_actor_denied(env):
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
other = {**CUSTOMER, "X-Debug-Actor": "CUST-1001"}
r = env["client"].post(f"/api/chat/sessions/{sid}/close", headers=other)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_SESSION_AGENT"
# 未关闭成功
row = _rows(env["engine"], "SELECT status FROM agent_session WHERE session_id = :s", s=sid)[0]
assert row["status"] == "active"
def test_close_not_found_and_agent_type_mismatch(env):
"""close 端点:会话不存在 404;会话在 customer 线、advisor 头关 → 403(评审 P2)。"""
r = env["client"].post("/api/chat/sessions/sess-nope/close", headers=CUSTOMER)
assert r.status_code == 404
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
r2 = env["client"].post(f"/api/chat/sessions/{sid}/close", headers=ADVISOR)
assert r2.status_code == 403 and r2.json()["error_code"] == "AUTH_403_SESSION_AGENT"
row = _rows(env["engine"], "SELECT status FROM agent_session WHERE session_id = :s", s=sid)[0]
assert row["status"] == "active"
def test_close_all_sessions(env):
"""批量关闭:仅 active;关闭后 status=active 列表为空。"""
sid1 = env["client"].post("/api/chat", json={"message": "a"}, headers=CUSTOMER).json()["session_id"]
sid2 = env["client"].post("/api/chat", json={"message": "b"}, headers=CUSTOMER).json()["session_id"]
r = env["client"].post("/api/chat/sessions/close-all", headers=CUSTOMER)
assert r.status_code == 200
body = r.json()
assert body["closed_count"] == 2
r2 = env["client"].get("/api/chat/sessions?status=active", headers=CUSTOMER)
assert r2.json()["total"] == 0
r3 = env["client"].get("/api/chat/sessions?status=closed", headers=CUSTOMER)
assert r3.json()["total"] >= 2
closed_ids = {s["session_id"] for s in r3.json()["items"]}
assert {sid1, sid2}.issubset(closed_ids)
def test_sessions_list_status_filter(env):
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
env["client"].post(f"/api/chat/sessions/{sid}/close", headers=CUSTOMER)
assert env["client"].get("/api/chat/sessions?status=active", headers=CUSTOMER).json()["total"] == 0
assert env["client"].get("/api/chat/sessions?status=closed", headers=CUSTOMER).json()["total"] == 1
def test_query_endpoints_limit_clamped(env):
"""分页参数钳制:limit 超上限(sessions 100 / messages 200)由 Query 拦 422(评审 P2)。"""
assert env["client"].get("/api/chat/sessions?limit=101", headers=CUSTOMER).status_code == 422
assert env["client"].get("/api/chat/sessions?offset=-1", headers=CUSTOMER).status_code == 422
r = env["client"].get("/api/chat/sessions?limit=100", headers=CUSTOMER)
assert r.status_code == 200 and r.json()["limit"] == 100
def test_query_endpoints_risk_manager_denied(env):
"""方案 B 拍板:查询/关闭端点与对话线同口径——risk_manager 一律 403 AUTH_403_ROLE。"""
for method, url in (
("get", "/api/chat/sessions"),
("get", "/api/chat/sessions/sess-x/messages"),
("post", "/api/chat/sessions/sess-x/close"),
("post", "/api/chat/sessions/close-all"),
):
r = getattr(env["client"], method)(url, headers=RISK_MANAGER)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_ROLE"
def test_query_endpoints_compliance_denied_on_risk_line(env):
"""F12:compliance 与 risk_manager 同口径,不得进 risk 对话线数据面。"""
for method, url in (
("get", "/api/chat/sessions"),
("post", "/api/chat/sessions/close-all"),
):
r = getattr(env["client"], method)(url, headers=COMPLIANCE_RISK)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_ROLE"
def test_query_endpoints_missing_agent_type(env):
r = env["client"].get(
"/api/chat/sessions",
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"
def test_sessions_list_jwt_channel(env):
"""JWT 通道:risk_officer 拉自己的 risk 线会话列表。"""
tok = auth_service.issue_dev_token(sub="STAFF-30001", roles=["risk_officer"])
headers = {"Authorization": f"Bearer {tok}", "X-Agent-Type": "risk"}
env["client"].post("/api/chat", json={"message": "看下预警"}, headers=headers)
r = env["client"].get("/api/chat/sessions", headers=headers)
assert r.status_code == 200
body = r.json()
assert body["total"] == 1 and body["items"][0]["agent_type"] == "risk"
assert body["items"][0]["actor_id"] == "STAFF-30001"