# requires: langgraph, pydantic, pytest from langgraph.checkpoint.memory import InMemorySaver from langgraph.types import Command from ticket_flow import FakeLLM, Mailer, build def run(text, llm=None, mailer=None, tid="t"): llm, mailer = llm or FakeLLM(), mailer or Mailer() app = build(llm, mailer, InMemorySaver()) cfg = {"configurable": {"thread_id": tid}} out = app.invoke({"ticket_id": tid, "text": text, "status": "new", "events": []}, cfg) return app, cfg, out, llm, mailer def test_small_refund_sent_without_approval(): _, _, out, _, mailer = run("申请退款 30 元") assert out["status"] == "sent" and "t" in mailer.sent def test_large_refund_waits_then_rejected(): app, cfg, _, _, mailer = run("申请退款 900 元") assert app.get_state(cfg).next == ("approve",) out = app.invoke(Command(resume={"ok": False, "note": "超过权限"}), cfg) assert out["status"] == "rejected" and not mailer.sent def test_transient_error_retried(): _, _, out, llm, _ = run("系统报错 500", llm=FakeLLM(fail_times=2)) assert out["status"] == "sent" and llm.calls == 3 def test_retry_exhausted_raises_and_can_resume(): llm = FakeLLM(fail_times=3) app = build(llm, Mailer(), InMemorySaver()) cfg = {"configurable": {"thread_id": "x"}} try: app.invoke({"ticket_id": "x", "text": "怎么开发票", "status": "new", "events": []}, cfg) raise AssertionError("应该抛出异常") except Exception as e: assert "超时" in str(e) assert app.get_state(cfg).next == ("draft",) # 停在失败的节点,分类结果保留 out = app.invoke(None, cfg) # 故障恢复后续跑 assert out["status"] == "sent" and out["events"].count("classified:question") == 1 def test_send_is_idempotent(): mailer = Mailer() mailer.send("a", "x") mailer.send("a", "y") assert mailer.sent == {"a": "x"}