Delete checkpoints before removing business fields. Suppress only an explicit CheckpointNotFoundError; propagate storage failures and cancellation so the endpoint cannot report false success. Prefer async checkpoint deletion and fail clearly when cleanup is unsupported. Classify SSE error events as 401 for authentication, 429 for rate limits, and 500 for other failures, using the same fixed messages as non-stream responses. Errors terminate the stream without a success done event or provider exception text in the response. Validation: 286 offline tests passed (19 added), including failed-delete HTTP retry with preserved business/checkpoint state, explicit missing threads, cancellation, missing cleanup capability, and HTTP SSE errors before and after text output.
147 lines
5.3 KiB
Python
147 lines
5.3 KiB
Python
"""作业 6 验收演示的 HTTP 层组合测试。
|
||
|
||
链路(对应计划作业 6):创建会话 -> /set_info 写车牌 -> /get_info 读回
|
||
-> /chat 继续核实 -> 分组读值 -> /delete_session -> 再读与再聊不泄漏。
|
||
|
||
fake LLM(LLM_PROVIDER=fake)保证本测试离线可跑、零真实模型调用。
|
||
"""
|
||
|
||
import json
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from src.agent.llm import workflow_fake_llm
|
||
from src.core.config import Config
|
||
from src.main import app
|
||
|
||
|
||
@pytest.fixture()
|
||
def client(monkeypatch):
|
||
"""langgraph 后端 + fake LLM 的测试客户端(lifespan 完整初始化)。"""
|
||
monkeypatch.setattr(Config, "AGENT_BACKEND", "langgraph")
|
||
monkeypatch.setattr(Config, "LLM_PROVIDER", "fake")
|
||
|
||
import src.agent.llm as llm_module
|
||
|
||
monkeypatch.setattr(
|
||
llm_module, "build_llm_service", lambda *a, **k: workflow_fake_llm()
|
||
)
|
||
with TestClient(app) as test_client:
|
||
yield test_client
|
||
|
||
|
||
TS = "20261022120000"
|
||
|
||
|
||
def test_assignment6_acceptance_flow(client):
|
||
session = "a6-acceptance-001"
|
||
|
||
# set_info 写入车牌(零 LLM:fake 未注入任何 responder,调用即报错)
|
||
response = client.post("/set_info", json={
|
||
"sessionId": session, "timeStamp": TS,
|
||
"key": "hphm1", "value": "苏B12345",
|
||
})
|
||
assert response.status_code == 200
|
||
assert response.json()["code"] == "200"
|
||
|
||
# get_info 单键读回
|
||
response = client.post("/get_info", json={
|
||
"sessionId": session, "timeStamp": TS, "key": "hphm1",
|
||
})
|
||
assert response.json()["value"] == '"苏B12345"'
|
||
|
||
# chat 使用业务状态继续(非流式,协议层解析 state 前缀)
|
||
response = client.post("/chat", json={
|
||
"sessionId": session, "timeStamp": TS,
|
||
"text": "我是刚才报警的驾驶员", "clientMode": "direct",
|
||
})
|
||
body = response.json()
|
||
assert body["code"] == "200"
|
||
assert body["nextStageCode"] in ("1001", "1002")
|
||
|
||
# 分组读值:三个分组齐全,未填字段为空串
|
||
response = client.post("/get_info", json={
|
||
"sessionId": session, "timeStamp": TS, "key": "all",
|
||
})
|
||
groups = json.loads(response.json()["value"])
|
||
assert set(groups) == {"acdinfo", "acdhuman1", "acdhuman2"}
|
||
assert groups["acdhuman1"]["hphm1"] == "苏B12345"
|
||
assert groups["acdhuman1"]["xm1"] == ""
|
||
|
||
# 未知 key 兼容:JSON 空串
|
||
response = client.post("/get_info", json={
|
||
"sessionId": session, "timeStamp": TS, "key": "no_such_key",
|
||
})
|
||
assert response.json()["value"] == '""'
|
||
|
||
# set_info 未知 key:白名单拒绝
|
||
response = client.post("/set_info", json={
|
||
"sessionId": session, "timeStamp": TS,
|
||
"key": "no_such_key", "value": "x",
|
||
})
|
||
assert response.json()["code"] == "500"
|
||
|
||
# 删除 -> 再读为空
|
||
response = client.request("DELETE", "/delete_session", json={
|
||
"sessionId": session, "timeStamp": TS,
|
||
})
|
||
assert response.json()["code"] == "200"
|
||
response = client.post("/get_info", json={
|
||
"sessionId": session, "timeStamp": TS, "key": "hphm1",
|
||
})
|
||
assert response.json()["value"] == '""'
|
||
|
||
# 重复删除幂等成功
|
||
response = client.request("DELETE", "/delete_session", json={
|
||
"sessionId": session, "timeStamp": TS,
|
||
})
|
||
assert response.json()["code"] == "200"
|
||
|
||
# 删除后再聊天:不注入旧业务数据,服务正常应答
|
||
response = client.post("/chat", json={
|
||
"sessionId": session, "timeStamp": TS,
|
||
"text": "新对话", "clientMode": "direct",
|
||
})
|
||
assert response.json()["code"] == "200"
|
||
|
||
|
||
def test_delete_checkpoint_failure_returns_error_and_retry_cleans_session(client, monkeypatch):
|
||
from unittest.mock import AsyncMock
|
||
from src.core import fastgpt_client as wiring
|
||
|
||
session = "delete-retry"
|
||
payload = {"sessionId": session, "timeStamp": TS}
|
||
assert client.post("/set_info", json={
|
||
**payload, "key": "hphm1", "value": "test-plate",
|
||
}).json()["code"] == "200"
|
||
assert client.post("/chat", json={
|
||
**payload, "text": "新对话",
|
||
}).json()["nextStageCode"] == "1001"
|
||
saver = wiring._langgraph_graph.checkpointer
|
||
config = {"configurable": {"thread_id": session}}
|
||
assert saver.get_tuple(config) is not None
|
||
original_delete = saver.adelete_thread
|
||
failing_delete = AsyncMock(side_effect=ConnectionError("private storage detail"))
|
||
monkeypatch.setattr(saver, "adelete_thread", failing_delete)
|
||
|
||
response = client.request("DELETE", "/delete_session", json=payload)
|
||
assert response.json()["code"] == "500"
|
||
assert response.json()["msg"] == "删除会话失败"
|
||
assert "private storage detail" not in response.text
|
||
failing_delete.assert_awaited_once_with(session)
|
||
assert saver.get_tuple(config) is not None
|
||
assert json.loads(client.post("/get_info", json={
|
||
**payload, "key": "hphm1",
|
||
}).json()["value"]) == "test-plate"
|
||
|
||
monkeypatch.setattr(saver, "adelete_thread", original_delete)
|
||
assert client.request("DELETE", "/delete_session", json=payload).json()["code"] == "200"
|
||
assert saver.get_tuple(config) is None
|
||
assert json.loads(client.post("/get_info", json={
|
||
**payload, "key": "hphm1",
|
||
}).json()["value"]) == ""
|
||
assert client.post("/chat", json={
|
||
**payload, "text": "新对话",
|
||
}).json()["nextStageCode"] == "1001"
|