Files
ZNJJ-api-server/test/api/test_assignment6_endpoints.py
T
Eric Wang 97de142e6d feat(storage): add selectable SQLite and PostgreSQL persistence
- 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.
2026-10-24 13:41:54 +08:00

149 lines
5.4 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.
"""作业 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"