Files
2026Technology-Competition/tests/test_server_api.py
T
lhl 215c605650 feat(server): Web 服务化(FastAPI + SQLite + 内嵌零构建前端)
参赛成果物 03「交互界面 + 数据存储」落地:
- src/genesis/server/: store.py(SQLite 会话持久化)、service.py(会话化服务层:
  上传→解析→确认→影响→确认→生成→QA)、app.py(api-design §2 核心端点 9 组)、
  static/index.html(内嵌单页,零构建无 node_modules 依赖)
- scripts/serve.py 启动入口(--fake 离线引擎 / 默认真实 LLM)
- pyproject 加 fastapi/uvicorn/python-multipart
- 修复 qa_loop._build meta={} 导致真实模板 {{doc_title}} 等占位符残留 DocxInjectError
- README Web 服务说明 + service_url 登记指引;design.md §12.5 记录(含同步执行/
  zip 既有系统/无 WebSocket 的诚实偏差标注)
- 测试:test_server_store/service/api 共 34 用例(TestClient 全链路 + zip 影响流程 + 错误分支)
全量 pytest 473 passed / 99.20%
2026-08-26 22:13:19 +08:00

282 lines
10 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 "html" in prev.json()
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