- Select one storage backend per instance via AGENT_STORAGE, retaining memory as the backward-compatible default. Manage the selected async repository, official checkpointer and connection pools in lifespan; fail startup on unavailable or uninitialized storage without fallback. - Add SQLite and PostgreSQL adapters, schema initialization CLI, sample PostgreSQL compose configuration and a Windows-compatible serve entry. SQLite enforces single-instance file ownership; PostgreSQL uses a dedicated pool for session advisory locks plus version/epoch fencing. - Make repository IO and commit callbacks awaitable. Persist business fields, a complete committed graph snapshot and turn result/version in one business transaction before success. Rebuild execution checkpoints from that snapshot, discarding uncommitted progress after interruption. - Support optional Idempotency-Key on durable chat requests. Replay committed results and return 409 on changed request content; delete removes business/history data and replay records while retaining only an epoch/version tombstone to reject stale commits. - Document migration, database switching boundaries, backup/deployment expectations and crash recovery. Ignore generated database files. Validation: 315 tests passed with real SQLite and isolated PostgreSQL, including abrupt child-process exits before/after business commit; both backends passed CLI migration and actual HTTP server restart smoke. Existing API tests were rechecked after isolating them from user storage. An initial Windows CreateProcess permission failure did not recur in the full diagnostic run; failure assertions were not skipped or relaxed.
149 lines
5.4 KiB
Python
149 lines
5.4 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")
|
||
# 通用 API 契约测试不连接用户 .env 中的真实持久化数据库。
|
||
monkeypatch.setattr(Config, "AGENT_STORAGE", "memory")
|
||
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"
|