Files
2026Technology-Competition/tests/test_server_api.py
T
lhl be79624db5 fix(chat): 前端审计修复——预览契约改HTML/状态机统一/进度错误持久化/chat_state.js抽取
- app.py: /result/preview 返回渲染 HTML(HTMLResponse) 而非 {html} JSON;新增 /chat_state.js 静态路由;预览缺失返回 404(RESULT_NOT_FOUND)
- chat.html: currentProject 统一为 draftProject;新会话继承已选项目(§4.2);上传区显隐/类型映射/抽屉脏检测改用 GenesisState;loadSession 按 role 渲染 progress/error;加 h1 标题
- chat_state.js(新): 抽取 autoBindProject/shouldHideUploadSelect/resolveUploadType/computeDrawerSnapshot/isDrawerDirty/buildWelcome 纯函数(U+1 分隔符)
- agent.py: 新增 _persist_progress/_store_error,进度与错误以 role 入库(重载可见)
- 测试: test_chat_state.js(11 passed) + test_frontend_audit_fixes.py(5) + 修正 test_server_api 旧 JSON 契约断言;全量 pytest 568 passed / 99.06%
2026-08-29 11:28:08 +08:00

360 lines
14 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.
"""S4FastAPI 端点测试(server/app.pyhttpx TestClient 全链路)。"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from genesis.server.app import create_app
from genesis.server.store import SessionStore
_SAMPLE = Path(__file__).resolve().parents[1] / "sample"
@pytest.fixture
def client(tmp_path):
app = create_app(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine="fake",
)
return TestClient(app)
def _upload_core(client, sid):
files = [
("requirements", "requirements_newdev.xlsx", _SAMPLE / "requirements_newdev.xlsx"),
("template", "template_design_ja.docx", _SAMPLE / "template_design_ja.docx"),
("write_instruction", "rules_design_ja.docx", _SAMPLE / "rules_design_ja.docx"),
("rules", "rules_entry_ja.docx", _SAMPLE / "rules_entry_ja.docx"),
]
for ft, name, path in files:
r = client.post(f"/api/sessions/{sid}/files",
data={"file_type": ft},
files={"file": (name, path.read_bytes())})
assert r.status_code == 200, r.text
def test_health(client):
r = client.get("/api/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_create_and_get_session(client):
r = client.post("/api/sessions", json={"user_id": "u1"})
assert r.status_code == 200
sid = r.json()["session_id"]
g = client.get(f"/api/sessions/{sid}")
assert g.status_code == 200
assert g.json()["status"] == "uploading"
def test_session_list(client):
client.post("/api/sessions", json={"user_id": "u1"})
client.post("/api/sessions", json={"user_id": "u1"})
r = client.get("/api/sessions", params={"user_id": "u1"})
assert r.status_code == 200
assert len(r.json()) == 2
def test_full_flow_http(tmp_path):
"""HTTP 全链路:建会话 → 上传 → 解析 → 确认 → 生成 → QA → 预览/下载。"""
app = create_app(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine="fake",
)
client = TestClient(app)
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
_upload_core(client, sid)
r = client.post(f"/api/sessions/{sid}/start-parse")
assert r.status_code == 200
assert client.get(f"/api/sessions/{sid}").json()["status"] == "awaiting_parse_confirm"
pr = client.get(f"/api/sessions/{sid}/parse-result")
assert pr.status_code == 200
assert "tables" in pr.json()
r = client.post(f"/api/sessions/{sid}/confirm-parse")
assert r.status_code == 200
assert client.get(f"/api/sessions/{sid}").json()["status"] == "writing" # 无既有系统 → 影响跳过
r = client.post(f"/api/sessions/{sid}/generate", json={"output_language": "auto"})
assert r.status_code == 200
assert client.get(f"/api/sessions/{sid}").json()["result_path"]
r = client.post(f"/api/sessions/{sid}/run-qa")
assert r.status_code == 200
got = client.get(f"/api/sessions/{sid}").json()
assert got["status"] == "done"
assert "overall_score" in got["qa_summary"]
prev = client.get(f"/api/sessions/{sid}/result/preview")
assert prev.status_code == 200
assert "text/html" in prev.headers["content-type"]
assert prev.text.lstrip().lower().startswith("<!doctype html>")
dl = client.get(f"/api/sessions/{sid}/result/download")
assert dl.status_code == 200
assert dl.headers["content-type"].startswith("application/vnd.openxmlformats")
def test_state_transition_invalid_409(client):
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
# uploading 直接 generate → 409
r = client.post(f"/api/sessions/{sid}/generate", json={})
assert r.status_code == 409
def test_upload_invalid_type_400(client):
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
r = client.post(f"/api/sessions/{sid}/files",
data={"file_type": "bogus"},
files={"file": ("x.txt", b"x")})
assert r.status_code == 400
def test_root_serves_frontend(client):
r = client.get("/")
assert r.status_code == 200
assert "Genesis" in r.text
def test_delete_session(client):
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
r = client.delete(f"/api/sessions/{sid}")
assert r.status_code == 200
assert r.json()["deleted"] is True
assert client.get(f"/api/sessions/{sid}").status_code == 404
def test_generate_requires_confirm_409(client):
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
_upload_core(client, sid)
# 未确认解析就生成 → 409
r = client.post(f"/api/sessions/{sid}/generate", json={})
assert r.status_code == 409
def test_start_impact_without_existing_system_409(client):
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
_upload_core(client, sid)
client.post(f"/api/sessions/{sid}/start-parse")
client.post(f"/api/sessions/{sid}/confirm-parse")
# 无既有系统 → writingstart-impact 409
r = client.post(f"/api/sessions/{sid}/start-impact")
assert r.status_code == 409
def test_qa_requires_generate_409(client):
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
_upload_core(client, sid)
client.post(f"/api/sessions/{sid}/start-parse")
client.post(f"/api/sessions/{sid}/confirm-parse")
r = client.post(f"/api/sessions/{sid}/run-qa")
assert r.status_code == 409
def test_report_downloads_after_full_flow(tmp_path):
"""QA/影响报告下载端点(FileResponse 分支)。"""
app = create_app(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine="fake",
)
client = TestClient(app)
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
_upload_core(client, sid)
client.post(f"/api/sessions/{sid}/start-parse")
client.post(f"/api/sessions/{sid}/confirm-parse")
client.post(f"/api/sessions/{sid}/generate", json={})
client.post(f"/api/sessions/{sid}/run-qa")
qa = client.get(f"/api/sessions/{sid}/result/qa-report")
assert qa.status_code == 200
assert "application/json" in qa.headers["content-type"]
qa_body = client.get(f"/api/sessions/{sid}/qa-result")
assert qa_body.status_code == 200
assert "overall_score" in qa_body.json()
# 无影响报告(未跑影响)→ 404
assert client.get(f"/api/sessions/{sid}/result/impact-report").status_code == 404
def test_session_not_found_404(client):
assert client.get("/api/sessions/nope").status_code == 404
def test_upload_to_missing_session_404(client):
r = client.post("/api/sessions/nope/files",
data={"file_type": "requirements"},
files={"file": ("a.xlsx", b"x")})
assert r.status_code == 404
def test_start_parse_no_files_409(client):
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
assert client.post(f"/api/sessions/{sid}/start-parse").status_code == 409
def test_start_parse_missing_session_404(client):
assert client.post("/api/sessions/nope/start-parse").status_code == 404
def test_confirm_parse_wrong_state_409(client):
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
# uploading 态直接确认解析 → 409
assert client.post(f"/api/sessions/{sid}/confirm-parse").status_code == 409
def _upload_zip(client, sid):
import io
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("demo/OrderController.java",
"package demo;\n@RestController public class OrderController {}\n")
client.post(f"/api/sessions/{sid}/files",
data={"file_type": "existing_system"},
files={"file": ("existing.zip", buf.getvalue())})
def test_impact_flow_http_zip(tmp_path):
"""HTTP 影响调查全流程 + 报告下载(app 164/168-170/174-178/226)。"""
app = create_app(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine="fake",
)
client = TestClient(app)
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
_upload_core(client, sid)
_upload_zip(client, sid)
client.post(f"/api/sessions/{sid}/start-parse")
assert client.post(f"/api/sessions/{sid}/confirm-parse").json()["status"] == "impact_running"
r = client.post(f"/api/sessions/{sid}/start-impact")
assert r.status_code == 200
ir = client.get(f"/api/sessions/{sid}/impact-result")
assert ir.status_code == 200 and "summary" in ir.json()
assert client.post(f"/api/sessions/{sid}/confirm-impact").json()["status"] == "writing"
dl = client.get(f"/api/sessions/{sid}/result/impact-report")
assert dl.status_code == 200 and "application/json" in dl.headers["content-type"]
def test_confirm_impact_wrong_state_409(client):
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
_upload_core(client, sid)
client.post(f"/api/sessions/{sid}/start-parse")
client.post(f"/api/sessions/{sid}/confirm-parse")
# writing(无既有系统)直接确认影响 → 409
assert client.post(f"/api/sessions/{sid}/confirm-impact").status_code == 409
def test_download_result_not_found_404(client):
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
assert client.get(f"/api/sessions/{sid}/result/download").status_code == 404
assert client.get(f"/api/sessions/{sid}/result/qa-report").status_code == 404
def test_real_mode_generate_without_key_500(tmp_path, monkeypatch):
"""engine=None(真实模式)→ generate 按需 buildbuild 抛错 → 500。"""
import genesis.server.app as app_mod
def boom():
raise RuntimeError("LLMNotConfiguredError: 缺少 API Key")
monkeypatch.setattr(app_mod, "build_inference_engine", boom)
app = create_app(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine=None,
)
client = TestClient(app, raise_server_exceptions=False)
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
_upload_core(client, sid)
client.post(f"/api/sessions/{sid}/start-parse")
client.post(f"/api/sessions/{sid}/confirm-parse")
r = client.post(f"/api/sessions/{sid}/generate", json={})
assert r.status_code == 500
# ---------- 项目配置(S6 ----------
def test_create_and_list_projects(client):
body = {
"name": "stock", "display_name": "股票系统",
"template": str(_SAMPLE / "template_design_ja.docx"),
"write_instruction": "", "rules": [],
"existing_system_code_dir": "", "design_docs_dir": "",
}
r = client.post("/api/projects", json=body)
assert r.status_code == 200
assert r.json()["name"] == "stock"
lst = client.get("/api/projects").json()
assert any(p["name"] == "stock" for p in lst)
one = client.get("/api/projects/stock").json()
assert one["template"] == str(_SAMPLE / "template_design_ja.docx")
def test_project_invalid_template_400(client):
r = client.post("/api/projects", json={"name": "p", "template": "/no/such.docx"})
assert r.status_code == 400
assert r.json()["detail"]["code"] == "PROJECT_CONFIG_INVALID"
def test_delete_project(client):
client.post("/api/projects", json={"name": "p", "template": str(_SAMPLE / "template_design_ja.docx")})
assert client.delete("/api/projects/p").json()["deleted"] is True
assert client.get("/api/projects/p").status_code == 404
def test_session_accepts_name_and_project(client):
r = client.post("/api/sessions", json={"user_id": "u1", "name": "我的会话", "project": "projA"})
assert r.status_code == 200
sid = r.json()["session_id"]
assert r.json()["name"] == "我的会话"
g = client.get(f"/api/sessions/{sid}").json()
assert g["name"] == "我的会话"
assert g["project"] == "projA"
def test_session_list_includes_name_project(client):
client.post("/api/sessions", json={"user_id": "u1", "name": "n1", "project": "pA"})
r = client.get("/api/sessions", params={"user_id": "u1"}).json()
assert any(s["name"] == "n1" and s["project"] == "pA" for s in r)
def test_generate_with_project_config_no_template_upload(client):
"""绑定项目(含模板/规则)后,仅上传要件定义即可解析生成。"""
client.post("/api/projects", json={
"name": "projA", "template": str(_SAMPLE / "template_design_ja.docx"),
"write_instruction": str(_SAMPLE / "rules_design_ja.docx"),
"rules": [str(_SAMPLE / "rules_entry_ja.docx")],
"existing_system_code_dir": "", "design_docs_dir": "",
})
sid = client.post("/api/sessions", json={"user_id": "u1", "project": "projA"}).json()["session_id"]
# 仅上传要件定义
r = client.post(f"/api/sessions/{sid}/files", data={"file_type": "requirements"},
files={"file": ("requirements_newdev.xlsx", (_SAMPLE / "requirements_newdev.xlsx").read_bytes())})
assert r.status_code == 200
p = client.post(f"/api/sessions/{sid}/start-parse")
assert p.status_code == 200, p.text
assert client.post(f"/api/sessions/{sid}/confirm-parse").status_code == 200
# 无既有系统 → 直接 writing,可生成
gen = client.post(f"/api/sessions/{sid}/generate", json={})
assert gen.status_code == 200, gen.text
assert client.get(f"/api/sessions/{sid}/result/download").status_code == 200
def test_api_list_sessions_filter_by_project(client):
r0 = client.post("/api/sessions", json={"user_id": "u1", "project": "stock"})
r1 = client.post("/api/sessions", json={"user_id": "u1", "project": "other"})
res = client.get("/api/sessions", params={"user_id": "u1", "project": "stock"})
assert res.status_code == 200
ids = {x["session_id"] for x in res.json()}
assert r0.json()["session_id"] in ids
assert r1.json()["session_id"] not in ids