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%
This commit is contained in:
lhl
2026-08-26 22:13:19 +08:00
parent a1fefd43eb
commit 215c605650
14 changed files with 1474 additions and 1 deletions
+281
View File
@@ -0,0 +1,281 @@
"""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