Files
ZNJJ-api-server/test/services/test_app_service.py
T
Eric Wang 3caf17494f fix(api): report checkpoint deletion failures and classify SSE errors
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.
2026-10-23 19:48:29 +08:00

331 lines
12 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:应用服务四接口组合测试。
验收演示对应(计划作业 6):
创建会话 -> set_info 写车牌 -> get_info 读回 -> chat 使用车牌继续核实
-> 分组读值 -> 删除 -> 再读与再次聊天均不泄漏旧数据。
spy 断言:set_info / get_info / delete_session 全程零模型调用。
"""
import json
import pytest
from src.backends.chat import ChatResult, TextDelta, FormUpdate
from src.services.app_service import AccidentAppService, SET_INFO_INVALID_KEY_MSG
from src.services.repository import InMemorySessionRepository
class SpyBackend:
"""记录调用的 ChatBackend 替身;form_update 模拟图产出的字段差量。"""
def __init__(self, form_update=None):
self.calls = [] # complete 收到的 ChatInput
self.stream_calls = []
self._form_update = form_update or {}
# 用调用次数当“模型调用”spy:真实 backend 每次 complete 至少一次 LLM
self.llm_calls = 0
async def complete(self, chat_input):
self.calls.append(chat_input)
self.llm_calls += 1
return ChatResult(
content="<state>1002</state>收到",
status_code=None,
form_update=dict(self._form_update),
)
async def stream(self, chat_input):
self.stream_calls.append(chat_input)
self.llm_calls += 1
yield TextDelta("<state>1002</state>")
if self._form_update:
yield FormUpdate(dict(self._form_update))
yield TextDelta("收到")
def make_service(form_update=None):
backend = SpyBackend(form_update=form_update)
service = AccidentAppService(backend, InMemorySessionRepository())
return service, backend
# ---- set/get:白名单、分组、外部表示 ----
@pytest.mark.asyncio
async def test_set_then_get_single_key_roundtrip():
service, backend = make_service()
code, msg = await service.set_info("s1", "hphm1", "苏B12345")
assert (code, msg) == ("200", "")
assert json.loads(await service.get_info("s1", "hphm1")) == "苏B12345"
# set/get 零模型调用
assert backend.llm_calls == 0
@pytest.mark.asyncio
async def test_get_groups_match_existing_contract():
service, _ = make_service()
await service.set_info("s1", "ywrysw", "0") # False
await service.set_info("s1", "jdcsl", "0") # int 0
await service.set_info("s1", "xm2", "张三")
acd = json.loads(await service.get_info("s1", "acdinfo"))
assert acd["ywrysw"] == "0" # False -> "0"
assert acd["jdcsl"] == "0" # int 0 -> "0"(内部类型不同)
assert acd["sgyy"] == "" # 未知 -> 空串
human2 = json.loads(await service.get_info("s1", "acdhuman2"))
assert human2["xm2"] == "张三"
assert human2["sfzmwh2"] == "" # 全字段齐全,未填为空
all_groups = json.loads(await service.get_info("s1", "all"))
assert set(all_groups) == {"acdinfo", "acdhuman1", "acdhuman2"}
@pytest.mark.asyncio
async def test_get_unknown_key_returns_json_empty_string():
"""兼容现状:未知 key 返回 JSON 空串 '""'。"""
service, _ = make_service()
await service.set_info("s1", "hphm1", "苏B12345")
assert await service.get_info("s1", "no_such_key") == '""'
assert await service.get_info("ghost-session", "hphm1") == '""'
@pytest.mark.asyncio
async def test_set_unknown_or_internal_key_rejected():
service, backend = make_service()
code, msg = await service.set_info("s1", "no_such_key", "x")
assert code == "500" and msg == SET_INFO_INVALID_KEY_MSG
code, _ = await service.set_info("s1", "stage_code", "1002")
assert code == "500" # 内部字段保护
assert await service.get_info("s1", "hphm1") == '""' # 未被污染
assert backend.llm_calls == 0
@pytest.mark.asyncio
async def test_set_rejects_unparsable_value():
service, _ = make_service()
code, msg = await service.set_info("s1", "ywrysw", "maybe")
assert code == "500" and "无法解析" in msg
# ---- chat:注入 + 写回 ----
@pytest.mark.asyncio
async def test_chat_sees_set_info_values_and_writes_back_patch():
"""set_info 写入 -> chat 输入注入 -> 图差量写回 -> get_info 读到。"""
service, backend = make_service(form_update={"ywrysw": False})
await service.set_info("s1", "hphm1", "苏B12345")
await service.set_info("s1", "xm2", "李四")
result = await service.chat(
_input("s1", "我是刚才报警的驾驶员")
)
assert result.content == "<state>1002</state>收到"
# 图输入必须携带 repository 最新业务字段(车牌供核实使用)
injected = backend.calls[0].injected_fields
assert injected["person_fields"]["hphm1"] == "苏B12345"
assert injected["person_fields"]["xm2"] == "李四"
# 图产出的差量写回 repository,get_info 立即可读
assert json.loads(await service.get_info("s1", "acdinfo"))["ywrysw"] == "0"
assert json.loads(await service.get_info("s1", "hphm1")) == "苏B12345"
@pytest.mark.asyncio
async def test_chat_form_patch_mapped_to_business_keys():
"""图表单键差量经映射写回业务键(作业 6 映射的端到端)。"""
service, backend = make_service(form_update={
"has_injury": False, "vehicle_count": 2, "accident_cause": "追尾",
})
await service.chat(_input("s1", "两辆汽车追尾,没有人受伤"))
acd = json.loads(await service.get_info("s1", "acdinfo"))
assert acd["ywrysw"] == "0"
assert acd["jdcsl"] == "2"
assert acd["sgyy"] == "追尾"
# 表单键本身不落 repository(未知业务键 -> JSON 空串)
assert json.loads(await service.get_info("s1", "has_injury")) == ""
@pytest.mark.asyncio
async def test_set_info_acd_value_mapped_into_form_injection():
"""set_info 写业务键 -> chat 注入为图表单键(反向映射)。"""
service, backend = make_service()
await service.set_info("s1", "ywrysw", "0")
await service.set_info("s1", "jdcsl", "2")
await service.set_info("s1", "bjrjs", "1") # 无表单来源,不注入
await service.chat(_input("s1", "继续办理"))
injected = backend.calls[0].injected_fields
assert injected["accident_fields"] == {"has_injury": False, "vehicle_count": 2}
@pytest.mark.asyncio
async def test_chat_without_business_state_passes_no_injection():
service, backend = make_service()
await service.chat(_input("fresh-session", "你好"))
assert backend.calls[0].injected_fields is None
@pytest.mark.asyncio
async def test_stream_injects_fields_writes_back_form_update():
"""流式:注入同 chat;FormUpdate 事件差量捕获写回后透传。"""
service, backend = make_service(form_update={"has_injury": False})
await service.set_info("s1", "xm1", "王五")
events = [
event async for event in service.stream(_input("s1", "继续办理"))
]
kinds = [type(event).__name__ for event in events]
assert kinds == ["TextDelta", "FormUpdate", "TextDelta"]
assert backend.stream_calls[0].injected_fields["person_fields"]["xm1"] == "王五"
# FormUpdate 差量已写回 repository(表单键 -> 业务键)
assert json.loads(await service.get_info("s1", "acdinfo"))["ywrysw"] == "0"
# ---- delete:清理 + 幂等 ----
@pytest.mark.asyncio
async def test_delete_clears_business_state_and_is_idempotent():
drops = []
async def drop_checkpoint(session_id):
drops.append(session_id)
service, backend = make_service()
await service.set_info("s1", "hphm1", "苏B12345")
await service.chat(_input("s1", "你好"))
await service.delete_session("s1", drop_checkpoint=drop_checkpoint)
assert drops == ["s1"]
# 再读为空、再聊天不注入旧数据
assert await service.get_info("s1", "hphm1") == '""'
await service.chat(_input("s1", "还在吗"))
assert backend.calls[-1].injected_fields is None
# 重复删除固定行为:成功返回;checkpoint 清理会再次触发,
# 但缺失时静默容忍(幂等语义由 drop_checkpoint 内部保证)
await service.delete_session("s1", drop_checkpoint=drop_checkpoint)
assert drops == ["s1", "s1"]
assert backend.llm_calls == 2 # 两次 chat 各一次;set/get/delete 零调用
@pytest.mark.asyncio
async def test_delete_tolerates_missing_checkpoint():
from src.services.repository import CheckpointNotFoundError
async def missing(_session_id):
raise CheckpointNotFoundError("no such thread")
service, _ = make_service()
await service.set_info("s1", "xm1", "王五")
# checkpoint 不存在视为删除成功的一部分,不向上抛
await service.delete_session("s1", drop_checkpoint=missing)
assert await service.get_info("s1", "xm1") == '""'
def _input(session_id, text):
from src.backends.chat import ChatInput
return ChatInput(session_id=session_id, text=text, client_mode="direct", need_form_update=True)
# ---- 并发隔离(A2):同会话串行、不同会话并行、delete 与 chat 互斥 ----
import asyncio
class SerialProbeBackend:
"""记录 complete 执行区间的 backend:start/end 事件对可判断是否交错。"""
def __init__(self, delay: float = 0.03):
self.events: list[tuple[str, str]] = []
self._delay = delay
async def complete(self, chat_input):
self.events.append(("start", chat_input.text))
await asyncio.sleep(self._delay)
self.events.append(("end", chat_input.text))
return ChatResult(content="<state>1002</state>收到")
async def stream(self, chat_input):
self.events.append(("start", chat_input.text))
await asyncio.sleep(self._delay)
self.events.append(("end", chat_input.text))
yield TextDelta("<state>1002</state>收到")
def _paired(events: list[tuple[str, str]]) -> bool:
"""每个 start 与其 end 相邻配对(无交错)。顺序任意。"""
stack: list[str] = []
for kind, tag in events:
if kind == "start":
if stack:
return False
stack.append(tag)
else:
if not stack or stack.pop() != tag:
return False
return not stack
@pytest.mark.asyncio
async def test_concurrent_chats_same_session_serialize():
"""同会话两个并发 chat 必须串行(区间不交错)。"""
backend = SerialProbeBackend()
service = AccidentAppService(backend, InMemorySessionRepository())
await asyncio.gather(
service.chat(_input("s1", "a")),
service.chat(_input("s1", "b")),
)
assert sorted(e[1] for e in backend.events) == ["a", "a", "b", "b"]
assert _paired(backend.events)
@pytest.mark.asyncio
async def test_concurrent_chats_different_sessions_run_parallel():
"""不同会话的 chat 互不阻塞(第二个 start 在第一个 end 之前)。"""
backend = SerialProbeBackend(delay=0.05)
service = AccidentAppService(backend, InMemorySessionRepository())
await asyncio.gather(
service.chat(_input("s1", "a")),
service.chat(_input("s2", "b")),
)
starts_before_first_end = [
tag for kind, tag in backend.events[:2] if kind == "start"
]
assert sorted(starts_before_first_end) == ["a", "b"]
@pytest.mark.asyncio
async def test_concurrent_delete_serialization():
"""在途 chat 与 delete 互斥:delete 等 chat 完成后才执行。"""
backend = SerialProbeBackend()
service = AccidentAppService(backend, InMemorySessionRepository())
async def noop_drop(_session_id):
backend.events.append(("drop", "s1"))
await asyncio.gather(
service.chat(_input("s1", "a")),
service.delete_session("s1", drop_checkpoint=noop_drop),
)
# drop 必须出现在 chat 的 end 之后
kinds = [e for e in backend.events]
drop_index = next(i for i, e in enumerate(kinds) if e[0] == "drop")
end_index = next(i for i, e in enumerate(kinds) if e == ("end", "a"))
assert drop_index > end_index
@pytest.mark.asyncio
async def test_concurrent_chat_and_set_info_no_corruption():
"""set_info 与 chat 按同一会话串行,最终保留写入的完整车牌。"""
backend = SerialProbeBackend()
service = AccidentAppService(backend, InMemorySessionRepository())
results = await asyncio.gather(
service.chat(_input("s1", "a")),
service.set_info("s1", "hphm1", "苏B12345"),
)
assert results[1] == ("200", "")
# set 写入在 chat 结束后执行,不能丢失。
assert json.loads(await service.get_info("s1", "hphm1")) == "苏B12345"