Files
PlantAI-rag-new/bioflow_service/tests/test_api.py
T

761 lines
27 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.
"""tests/test_api.py —— 生信服务端点(L2;mock docker/LLM/vstore)。
分层标记 -m bioflow_api。覆盖(TC-BIO-API 系列):
health / chat/stream SSE(plan_ready 候选/伪造阶段拒/非生信不建任务/降级)/
catalog / jobs 全套 / files(上传/路径登记/白名单)/ stages(confirm/rerun/skip/
pause/resume/manifest+日志尾)/ events SSE(快照+回放)/ archives(存档/列表/
复用/删除)/ 报告登记。
传输层说明:本环境 fastapi TestClient 的 sync stream 包装与 httpx ASGITransport
对永不结束的 SSE 流均存在首帧不达/缓冲问题(dbg3/dbg6 最小复现实证)——
SSE 测试端点直调迭代 body_iterator;普通 JSON 请求走 ASGITransport
(每请求独立 AsyncClient + 新 loop,无跨用例状态纠缠)。
"""
from __future__ import annotations
import asyncio
import json
import time
import pytest
pytestmark = pytest.mark.bioflow_api
# ---------------------------------------------------------------------------
# 传输辅助
# ---------------------------------------------------------------------------
# 架构说明:每个用例一个 ApiSession——独立事件循环 + 持久 AsyncClient(ASGITransport)。
# asyncio.set_event_loop 保证端点内 asyncio.create_task 派生的后台任务(runner 自动
# 推进)绑定到本 loop;_wait_until 轮询经同一 client 泵 loop,后台任务随之推进。
# 规避 TestClient sync stream 与 ASGITransport 对永不结束 SSE 流的缓冲问题
# (dbg3/dbg6 最小复现实证),SSE 用例端点直调迭代 body_iterator(见模块 docstring)。
def _run_async(coro):
"""在共享会话 loop 上跑协程(anyio portal 绑定首个 loop,跨 loop 会死锁)。"""
return ApiSession.shared().loop.run_until_complete(coro)
class ApiSession:
"""模块级单例会话:全模块共享一个 event loop 与 AsyncClient。
原因:anyio asyncio 后端缓存线程首个 event loop(portal),跨测试新建
loop 会导致 run_until_complete 死锁(实证:test_create_job 后续测试随机
hang)。单 loop 共享 + 每用例后 drain 后台任务,是确定性方案。
"""
_instance = None
def __init__(self) -> None:
import httpx
from app.main import app as _app
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
transport = httpx.ASGITransport(app=_app)
self._client = httpx.AsyncClient(transport=transport, base_url="http://t")
@classmethod
def shared(cls) -> "ApiSession":
if cls._instance is None:
cls._instance = cls()
asyncio.set_event_loop(cls._instance.loop)
return cls._instance
def request(self, method: str, url: str, **kw) -> "_Resp":
resp = self.loop.run_until_complete(self._client.request(method, url, **kw))
return _Resp(resp.status_code, resp.content, dict(resp.headers))
def get(self, url, **kw):
return self.request("GET", url, **kw)
def post(self, url, **kw):
return self.request("POST", url, **kw)
def delete(self, url, **kw):
return self.request("DELETE", url, **kw)
def drain(self) -> None:
"""取消全部遗留后台任务(上用例 runner 派生),泵干 loop。"""
import app.context as context_mod
async def _cancel():
ctx = context_mod.context
targets = list(ctx._tasks)
if ctx.runner is not None:
targets += list(ctx.runner._tasks)
for t in targets:
t.cancel()
await asyncio.sleep(0.05) # 给取消任务 finally(信号量释放)时间
return True
try:
self.loop.run_until_complete(asyncio.wait_for(_cancel(), timeout=3))
except (asyncio.TimeoutError, RuntimeError):
pass
def close(self) -> None:
self.drain()
self.loop.run_until_complete(self._client.aclose())
self.loop.close()
asyncio.set_event_loop(None)
ApiSession._instance = None
class _Resp:
"""httpx 响应最小封装(status_code/json/text)。"""
def __init__(self, status_code: int, content: bytes, headers=None) -> None:
self.status_code = status_code
self.content = content
self.headers = headers or {}
def json(self):
return json.loads(self.content.decode("utf-8"))
@pytest.fixture()
def api(api_context):
"""API 会话(模块级共享 loop + client;用例后取消遗留后台任务)。"""
session = ApiSession.shared()
yield session
session.drain()
def _wait_until(fn, timeout=5.0, interval=0.02):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = fn()
if value:
return value
time.sleep(interval)
return fn()
# ---------------------------------------------------------------------------
# SSE 辅助(端点直调;传输层限制见模块 docstring)
# ---------------------------------------------------------------------------
def _parse_sse_frame(frame_text: str) -> dict | None:
"""SSE 帧解析(event/data 两行一组;注释帧返回 None)。"""
lines = frame_text.split("\n")
event_type = None
data = None
for line in lines:
if line.startswith("event: "):
event_type = line[7:]
elif line.startswith("data: "):
data = line[6:]
if event_type and data:
try:
return {"type": event_type, "data": json.loads(data)}
except ValueError:
return {"type": event_type, "data": data}
if event_type:
return {"type": event_type, "data": None}
return None
async def _collect_endpoint_stream(resp, max_events: int = 10, deadline_sec: float = 5.0):
"""迭代端点返回的 StreamingResponse.body_iterator(限时收集)。"""
events: list[dict] = []
buf_holder = [""]
async def _iter():
async for frame in resp.body_iterator:
chunk = frame if isinstance(frame, (bytes, bytearray)) else str(frame).encode("utf-8")
buf_holder[0] += chunk.decode("utf-8")
while "\n\n" in buf_holder[0]:
frame_text, buf_holder[0] = buf_holder[0].split("\n\n", 1)
parsed = _parse_sse_frame(frame_text)
if parsed:
events.append(parsed)
if len(events) >= max_events:
return events
return events
try:
return await asyncio.wait_for(_iter(), timeout=deadline_sec)
except asyncio.TimeoutError:
return events
def _read_sse(endpoint_coro, max_events=10, deadline_sec=5.0):
"""端点直调版 SSE 读取。"""
async def go():
resp = await endpoint_coro
assert resp.status_code == 200
assert resp.media_type == "text/event-stream"
return await _collect_endpoint_stream(resp, max_events, deadline_sec)
return _run_async(go())
def _read_sse_status(endpoint_coro):
"""端点直调:只取状态码(404 等)。"""
async def go():
from fastapi import HTTPException
try:
resp = await endpoint_coro
return resp.status_code
except HTTPException as exc:
return exc.status_code
return _run_async(go())
def _chat_stream_call(message, **kw):
from app.routers import bioflow as r_flow
from app.schemas import ChatStreamRequest
body = ChatStreamRequest(session_id=kw.get("session_id", "s1"), message=message, **{
k: v for k, v in kw.items() if k != "session_id"})
return r_flow.chat_stream(body)
# ---------------------------------------------------------------------------
# health
# ---------------------------------------------------------------------------
def test_health(api):
"""TC-BIO-API-099:health 200 + db 连通状态。"""
resp = api.get("/api/bioflow/health")
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["status"] == "ok"
assert body["db"]["connected"] is True
# ---------------------------------------------------------------------------
# catalog
# ---------------------------------------------------------------------------
def test_catalog_all(api):
"""TC-BIO-API-021:catalog 返回全部阶段(映射表驱动,每项含核心字段)。"""
resp = api.get("/api/bioflow/catalog")
assert resp.status_code == 200
stages = resp.json()["data"]["stages"]
assert len(stages) >= 7 # 测试映射表 7 阶段(真实表 75)
item = [s for s in stages if s["name"] == "identify"][0]
assert {"name", "description", "image", "params", "inputs", "outputs"} <= set(item.keys())
def test_catalog_chain(api):
"""TC-BIO-API-022:基因家族链。"""
resp = api.get("/api/bioflow/catalog", params={"chain": "gene_family"})
assert resp.status_code == 200
stages = resp.json()["data"]["stages"]
assert [s["name"] for s in stages][:2] == ["genome_prepare", "identify"]
def test_catalog_stage_detail(api):
"""TC-BIO-API-023:单阶段参数 schema。"""
resp = api.get("/api/bioflow/catalog", params={"stage": "identify"})
assert resp.status_code == 200
stage = resp.json()["data"]["stage"]
for p in stage["params"]:
assert {"name", "type", "default", "source", "help"} <= set(p.keys())
assert stage["image"] == "bioflow-gf"
assert stage["description"] == "基因家族鉴定"
def test_catalog_unknown_stage(api):
"""TC-BIO-API-024:未知阶段 404。"""
resp = api.get("/api/bioflow/catalog", params={"stage": "nope"})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "NOT_FOUND"
def test_catalog_unknown_chain(api):
resp = api.get("/api/bioflow/catalog", params={"chain": "nope"})
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# chat/stream
# ---------------------------------------------------------------------------
def test_chat_stream_plan_ready(api_context):
"""TC-BIO-API-001/002:chat/stream SSE,plan_ready 下发候选。"""
events = _read_sse(_chat_stream_call("帮我跑水稻 SPL 家族鉴定"), max_events=1)
types = [e["type"] for e in events]
assert "plan_ready" in types
plan = [e for e in events if e["type"] == "plan_ready"][0]["data"]
assert 1 <= len(plan["candidates"]) <= 3
for c in plan["candidates"]:
assert c["stages"] and c["purpose"]
def test_chat_stream_unknown_stage_rejected(api_context, monkeypatch):
"""TC-BIO-API-003:伪造阶段被拒(候选不进候选集)。"""
from conftest import FakeLLM
import app.services.bioflow.planner as planner_mod
monkeypatch.setattr(planner_mod, "complete_json", FakeLLM(candidates=[
{"purpose": "坏", "stages": ["fake_stage_xyz"]},
{"purpose": "好", "stages": ["identify"]},
]))
events = _read_sse(_chat_stream_call("帮我跑水稻 SPL 家族鉴定"), max_events=1)
plan = [e for e in events if e["type"] == "plan_ready"][0]["data"]
for c in plan["candidates"]:
assert "fake_stage_xyz" not in c["stages"]
def test_chat_stream_non_bioflow_message(api_context):
"""TC-BIO-API-005:非生信消息不建任务。"""
events = _read_sse(_chat_stream_call("什么是基因家族"), max_events=1)
types = [e["type"] for e in events]
assert "message_done" in types
assert "job_created" not in types
done = [e for e in events if e["type"] == "message_done"][0]["data"]
assert done["kind"] == "qa"
def test_chat_stream_empty_message_422(api):
"""TC-BIO-API-006/007/008:422 校验。"""
assert api.post("/api/bioflow/chat/stream",
json={"session_id": "s1", "message": ""}).status_code == 422
assert api.post("/api/bioflow/chat/stream",
json={"session_id": "s1"}).status_code == 422
assert api.post("/api/bioflow/chat/stream",
json={"message": "x"}).status_code == 422
def test_chat_stream_overlong_rejected(api):
"""TC-BIO-API-015:超长消息拒绝,不触发 LLM。"""
resp = api.post("/api/bioflow/chat/stream",
json={"session_id": "s1", "message": "x" * 100001})
assert resp.status_code == 422
def test_chat_stream_llm_timeout_degrade(api_context, monkeypatch):
"""TC-BIO-API-009:LLM 超时降级(内置兜底候选不中断 SSE)。"""
from conftest import FakeLLM
import app.services.bioflow.planner as planner_mod
monkeypatch.setattr(planner_mod, "complete_json", FakeLLM(error="timeout"))
events = _read_sse(_chat_stream_call("帮我跑水稻 SPL 家族鉴定"), max_events=1)
plan = [e for e in events if e["type"] == "plan_ready"][0]["data"]
assert plan["fallback"] # 降级提示
assert plan["candidates"]
# ---------------------------------------------------------------------------
# jobs
# ---------------------------------------------------------------------------
def _create_job(api, stages=("identify",), auto_continue=False, **kw):
resp = api.post("/api/bioflow/jobs", json={
"session_id": kw.get("session_id", "s1"),
"title": kw.get("title", "t"),
"stages": list(stages),
"auto_continue": auto_continue,
})
return resp
def _job_id(resp):
return resp.json()["data"]["id"]
def _job_stage_status(api, job_id: str, idx: int = 0):
return api.get(f"/api/bioflow/jobs/{job_id}").json()["data"]["stages"][idx]["status"]
def test_create_job(api):
"""TC-BIO-API-027:POST jobs 创建任务(stages 校验 + 返回结构)。"""
resp = _create_job(api, title="SPL 鉴定")
assert resp.status_code == 201
data = resp.json()["data"]
assert data["id"]
assert data["title"] == "SPL 鉴定"
assert data["stages"][0]["stage"] == "identify"
assert data["status"] in ("created", "running", "done")
def test_create_job_missing_stages(api):
"""TC-BIO-API-028:缺 stages 422。"""
assert api.post("/api/bioflow/jobs",
json={"session_id": "s1", "title": "t"}).status_code == 422
def test_create_job_unknown_stage(api):
"""TC-BIO-API-030:未知 stage 拒绝。"""
resp = api.post("/api/bioflow/jobs", json={
"session_id": "s1", "title": "t", "stages": ["ghost_stage"],
})
assert resp.status_code == 422
assert "ghost_stage" in json.dumps(resp.json(), ensure_ascii=False)
def test_jobs_list_and_filter(api):
"""TC-BIO-API-032:列表按创建顺序倒序(rowid DESC)。"""
_create_job(api, title="t1")
_create_job(api, title="t2")
resp = api.get("/api/bioflow/jobs")
assert resp.status_code == 200
items = resp.json()["data"]["items"]
assert len(items) >= 2
assert items[0]["title"] == "t2" # 倒序
def test_job_detail_and_404(api):
resp = _create_job(api, stages=["identify", "phylogeny"])
job_id = _job_id(resp)
detail = api.get(f"/api/bioflow/jobs/{job_id}")
assert detail.status_code == 200
assert len(detail.json()["data"]["stages"]) == 2
assert "progress" in detail.json()["data"]
not_found = api.get("/api/bioflow/jobs/not-exist")
assert not_found.status_code == 404
assert not_found.json()["error"]["code"] == "NOT_FOUND"
def test_delete_job(api):
resp = _create_job(api)
job_id = _job_id(resp)
# 等待任务终态(done;删除只拒 running)
def terminal():
return api.get(f"/api/bioflow/jobs/{job_id}").json()["data"]["status"] in ("done", "failed")
_wait_until(terminal, timeout=5)
del_resp = api.delete(f"/api/bioflow/jobs/{job_id}")
assert del_resp.status_code == 204
assert api.get(f"/api/bioflow/jobs/{job_id}").status_code == 404
# ---------------------------------------------------------------------------
# files
# ---------------------------------------------------------------------------
def test_files_upload_and_list(api):
"""TC-BIO-API-039/048:上传 + 文件列表。"""
resp = _create_job(api)
job_id = _job_id(resp)
content = b">gene1\nACGTACGT\n"
up = api.post(f"/api/bioflow/jobs/{job_id}/files",
files={"file": ("test.fa", content, "application/octet-stream")})
assert up.status_code == 200
data = up.json()["data"]
assert data["name"] == "test.fa"
assert data["size"] == len(content)
listing = api.get(f"/api/bioflow/jobs/{job_id}/files")
assert listing.status_code == 200
assert listing.json()["data"]["items"][0]["name"] == "test.fa"
def test_files_upload_bad_extension(api):
"""TC-BIO-API-046:扩展名白名单外 422。"""
resp = _create_job(api)
job_id = _job_id(resp)
up = api.post(f"/api/bioflow/jobs/{job_id}/files",
files={"file": ("x.exe", b"MZ", "application/octet-stream")})
assert up.status_code == 422
def test_files_upload_no_job(api):
"""TC-BIO-API-120:上传无任务 404。"""
up = api.post("/api/bioflow/jobs/nope/files",
files={"file": ("a.fa", b"x", "text/plain")})
assert up.status_code == 404
def test_path_register_valid_and_whitelist(api, tmp_path):
"""TC-BIO-API-042/045:路径登记合法 + 白名单外 403。"""
resp = _create_job(api)
job_id = _job_id(resp)
# api_context 的 settings 已指向 tmp_path/data(conftest _isolated_env 派生)
from app.config import get_settings as _gs
data_root = _gs().data_root_abs
import os
allowed = os.path.join(data_root, "examples", "SPL.fa")
os.makedirs(os.path.dirname(allowed), exist_ok=True)
with open(allowed, "w") as f:
f.write(">x\nACGT")
ok = api.post(f"/api/bioflow/jobs/{job_id}/files", json={"server_path": allowed})
assert ok.status_code == 200
assert ok.json()["data"]["source"] == "server_path"
# 白名单外
outside = str(tmp_path / "outside.fa")
with open(outside, "w") as f:
f.write("x")
forbidden = api.post(f"/api/bioflow/jobs/{job_id}/files", json={"server_path": outside})
assert forbidden.status_code == 403
assert forbidden.json()["error"]["code"] == "PATH_NOT_ALLOWED"
# 不存在
missing = api.post(f"/api/bioflow/jobs/{job_id}/files",
json={"server_path": str(tmp_path / "nope.fa")})
assert missing.status_code == 400
assert missing.json()["error"]["code"] == "PATH_NOT_EXIST"
# ---------------------------------------------------------------------------
# stages 控制 + manifest
# ---------------------------------------------------------------------------
def test_confirm_flow(api):
"""TC-BIO-API-056/057:confirm 发起执行,参数覆盖。"""
resp = _create_job(api)
job_id = _job_id(resp)
def awaiting():
return _job_stage_status(api, job_id) in ("waiting_confirm", "done")
_wait_until(awaiting, timeout=5)
confirm = api.post(f"/api/bioflow/jobs/{job_id}/stages/identify/confirm",
json={"params": {"cpu": "8"}})
assert confirm.status_code == 200
assert confirm.json()["data"]["status"] in ("running", "done")
def done():
return _job_stage_status(api, job_id) == "done"
_wait_until(done, timeout=5)
assert _job_stage_status(api, job_id) == "done"
def test_confirm_unknown_param(api):
"""TC-BIO-API-058:拒绝非 schema 参数。"""
resp = _create_job(api)
job_id = _job_id(resp)
def waiting():
return _job_stage_status(api, job_id) in ("waiting_confirm", "done")
_wait_until(waiting, timeout=5)
confirm = api.post(f"/api/bioflow/jobs/{job_id}/stages/identify/confirm",
json={"params": {"magic": "x"}})
# waiting_confirm 时 422;已 done 时 409(两态均可——状态竞争)
assert confirm.status_code in (409, 422)
def test_confirm_unknown_stage_404(api):
resp = _create_job(api)
job_id = _job_id(resp)
confirm = api.post(f"/api/bioflow/jobs/{job_id}/stages/nope/confirm", json={})
assert confirm.status_code == 404
def test_manifest_with_log_tail(api):
"""TC-BIO-API-071:manifest + 日志尾 80 行。"""
resp = _create_job(api, auto_continue=True)
job_id = _job_id(resp)
def done():
return _job_stage_status(api, job_id) == "done"
_wait_until(done, timeout=5)
m = api.get(f"/api/bioflow/jobs/{job_id}/stages/identify/manifest")
assert m.status_code == 200
data = m.json()["data"]
assert data["manifest"]["status"] == "success"
assert "log_tail" in data
def test_manifest_unknown_stage(api):
resp = _create_job(api)
job_id = _job_id(resp)
m = api.get(f"/api/bioflow/jobs/{job_id}/stages/nope/manifest")
assert m.status_code == 404
def test_rerun_and_skip_on_failed(api, api_context):
"""TC-BIO-API-062/065:rerun/skip 失败阶段。"""
api_context.executor.exit_code = 1
resp = _create_job(api, auto_continue=True)
job_id = _job_id(resp)
def failed():
return _job_stage_status(api, job_id) == "failed"
_wait_until(failed, timeout=5)
# skip
skip = api.post(f"/api/bioflow/jobs/{job_id}/stages/identify/skip")
assert skip.status_code == 200
assert skip.json()["data"]["status"] == "skipped"
def test_rerun_non_failed_409(api):
"""TC-BIO-API-064:done 阶段 rerun 409。"""
resp = _create_job(api, auto_continue=True)
job_id = _job_id(resp)
def done():
return _job_stage_status(api, job_id) == "done"
_wait_until(done, timeout=5)
rerun = api.post(f"/api/bioflow/jobs/{job_id}/stages/identify/rerun", json={})
assert rerun.status_code == 409
assert rerun.json()["error"]["code"] == "STAGE_NOT_FAILED"
# ---------------------------------------------------------------------------
# events SSE(快照 + 回放)
# ---------------------------------------------------------------------------
def test_events_snapshot_and_replay(api):
"""TC-BIO-API-017/078:events 首帧快照 + 断线重连回放。"""
resp = _create_job(api, auto_continue=True)
job_id = _job_id(resp)
def done():
return _job_stage_status(api, job_id) == "done"
_wait_until(done, timeout=5)
from app.routers import biojobs as r_jobs
events = _read_sse(r_jobs.job_events(job_id, stage=None, last_seq=-1), max_events=9)
frames = [e["type"] for e in events]
# 快照首帧
assert frames[0] == "snapshot"
# 历史事件回放含 job_created/stage_started/stage_done
assert "job_created" in frames
assert "stage_started" in frames
assert "stage_done" in frames
def test_events_unknown_job_404(api):
"""TC-BIO-API-126:events 未知 job 404。"""
from app.routers import biojobs as r_jobs
status = _read_sse_status(r_jobs.job_events("nope", stage=None, last_seq=-1))
assert status == 404
# ---------------------------------------------------------------------------
# archives + 报告登记
# ---------------------------------------------------------------------------
def _make_archive(api):
"""创建已 done 任务并存档(返回 (job_id, archive_id);测试辅助)。"""
resp = _create_job(api, auto_continue=True)
job_id = _job_id(resp)
def done():
return _job_stage_status(api, job_id) == "done"
_wait_until(done, timeout=5)
arch = api.post("/api/bioflow/archives", json={
"job_id": job_id, "name": "SPL 水稻鉴定", "description": "d",
"include_intermediate": True,
})
assert arch.status_code == 201
return job_id, arch.json()["data"]["id"]
def test_archive_create_and_list(api):
"""TC-BIO-API-087/090:存档(name/disk_path/size)+ 列表。"""
resp = _create_job(api, auto_continue=True)
job_id = _job_id(resp)
def done():
return _job_stage_status(api, job_id) == "done"
_wait_until(done, timeout=5)
arch = api.post("/api/bioflow/archives", json={
"job_id": job_id, "name": "SPL 水稻鉴定", "description": "d",
"include_intermediate": True,
})
assert arch.status_code == 201
data = arch.json()["data"]
assert data["name"] == "SPL 水稻鉴定"
assert data["disk_path"]
assert data["size_bytes"] > 0
listing = api.get("/api/bioflow/archives")
assert listing.status_code == 200
assert listing.json()["data"]["items"][0]["name"] == "SPL 水稻鉴定"
_last_archive = (job_id, data["id"])
return None
def test_archive_missing_name(api):
resp = _create_job(api)
job_id = _job_id(resp)
arch = api.post("/api/bioflow/archives", json={"job_id": job_id})
assert arch.status_code == 422
def test_archive_duplicate_name_409(api):
job_id, _aid = _make_archive(api)
arch = api.post("/api/bioflow/archives", json={
"job_id": job_id, "name": "SPL 水稻鉴定",
})
assert arch.status_code == 409
assert arch.json()["error"]["code"] == "ARCHIVE_EXISTS"
def test_archive_detail_delete(api):
_job_id_, aid = _make_archive(api)
detail = api.get(f"/api/bioflow/archives/{aid}")
assert detail.status_code == 200
assert detail.json()["data"]["stage_chain"] == ["identify"]
deleted = api.delete(f"/api/bioflow/archives/{aid}")
assert deleted.status_code == 204
assert api.get(f"/api/bioflow/archives/{aid}").status_code == 404
assert api.delete("/api/bioflow/archives/nope").status_code == 404
def test_archive_run_reuse(api):
"""TC-BIO-API-094:复用发起(新任务 stages=归档链)。"""
_job_id_, aid = _make_archive(api)
run = api.post(f"/api/bioflow/archives/{aid}/run", json={
"session_id": "s2", "target": "玉米 bZIP",
})
assert run.status_code == 201
data = run.json()["data"]
assert [s["stage"] for s in data["stages"]] == ["identify"]
assert "玉米 bZIP" in data["title"]
# 按名称匹配复用
by_name = api.post("/api/bioflow/archives/run", json={
"session_id": "s3", "name": "SPL 水稻鉴定",
})
assert by_name.status_code == 201
# 无匹配 404
missing = api.post("/api/bioflow/archives/run", json={
"session_id": "s3", "name": "不存在",
})
assert missing.status_code == 404
assert missing.json()["error"]["code"] == "ARCHIVE_NOT_FOUND"
def test_archive_report(api):
_job_id_, aid = _make_archive(api)
report = api.get(f"/api/bioflow/archives/{aid}/report")
# 该任务无报告(无报告链阶段)→ 404 或 200(有 html 时)
assert report.status_code in (200, 404)
def test_reports_register(api):
"""TC-BIO-API-104 服务侧:报告登记归一化 + 图清单回填。"""
resp = _create_job(api, auto_continue=True)
job_id = _job_id(resp)
def done():
return _job_stage_status(api, job_id) == "done"
_wait_until(done, timeout=5)
reg = api.post("/api/bioflow/reports/register", json={
"job_id": job_id, "session_id": "s1", "title": "SPL 报告",
"md_path": "/tmp/x.html", "figures": ["fig1"],
})
assert reg.status_code == 200
data = reg.json()["data"]
assert data["job_id"] == job_id
assert data["figures"] == ["fig1"]
assert data["registered_at"]
def test_report_revise_no_chart_target(api):
"""TC-BIO-API-144:无绘图目标 422。"""
resp = _create_job(api)
job_id = _job_id(resp)
rev = api.post(f"/api/bioflow/report/{job_id}/revise", json={
"instruction": "不明确",
})
assert rev.status_code == 422