From e980f1a34910d0b3a8c64cfdd0ed217ea601c705 Mon Sep 17 00:00:00 2001 From: baozaotumao2025 Date: Sat, 18 Jul 2026 23:49:41 +0800 Subject: [PATCH] =?UTF-8?q?feature:=20/api/v1/runs/{run=5Fid}/export=20?= =?UTF-8?q?=E6=8C=89=E7=85=A7=E9=9C=80=E6=B1=82=E5=AF=BC=E5=87=BA=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coverage | Bin 53248 -> 53248 bytes README.md | 12 +++- app/api/v1/endpoints/runs.py | 39 +++++++++++- app/services/export_service.py | 95 ++++++++++++++++++++++++++++ docs/tutorial/getting-started.md | 16 ++++- docs/tutorial/operations.md | 18 +++++- scripts/export_run.py | 102 +------------------------------ tests/test_api_contracts.py | 69 +++++++++++++++++++++ 8 files changed, 245 insertions(+), 106 deletions(-) create mode 100644 app/services/export_service.py diff --git a/.coverage b/.coverage index 897994e00b3cd33ba232e8c56c0311861773b66c..d75c75e0f88b2e180758b808bdf4d1291615c2f7 100644 GIT binary patch delta 2792 zcmZXWdr(x@9mn^+_wK#-oPF_TS>6wwij7TMCy^b%RS^)wvJz2uSrL`C0m~YwRoO6; z{Lyqej^2qGH3ldBqn%DuA8E9P)HTU;tkz0nI%7;LYhvrBCbB9jBCw~wdjv`+{PQ{A z-}#;2d411aUOK|Pbc8#`=bBl3-GoQ*^>=L6zhk7boC|Lag@d8)w(vrIq&yW8zBJ)4 z@dHTsCyuC`i<#+XEnJq|)7~BK=<3u=CewV=Q4{_hejbbHC~851c3ktTqv}ER`^sgd zN6ECDw{%*td{XwA@0$;rz0!55PjZRB7dMMoI4!jB1J`O7*uJ zd~B#hKU}|&Dc-j#z!d4hRqNQMLOoH}#QF>Lq}Qv*>ID54bqX8I*DF8{SIPSPDx27v z2PM69hFq^r|KUaq)x3JHm)Gl;E37{os`}ZwEM04;6Sri- z7$1yTsef3R$z;q-k=Fi`ZC%~lgW>k>o{qM5SZ!HtG+S)8 zgIqK%C2j6Vn=#TjfVyhTuC?kFRcplt4Cw`Wf6Y>DV<>E_v;{$Au@TW%uaNYK<*J~~ zi8urmR6Xtg?FyHmfbvc&O?THQVzUK8i}d3SRr>WhUcXkWiLEltGS;}O)}j~I+66O2 z7Sm|EBwg99MuXzOkdZEp|02nOqzGC&57svNag9|! zy#g~TJw9BMB`C8OY8*7Unz7KnCx*5O@@yqAG-K93Uh5O2*-M2r%vrBE6Nk?e5`?)| zICro7b3E9)(!b0+ZEbGmy5>5Yg^d=;^c8@!Pgtk0#+fw3;0&HL;mLPwtqB|g$#I%& ze9Ox;mkE#KyEu+x_zE7vNAP~!joWb=M(};Vi~fZ^MHkTr=rlTxevS5_S5XKxqdK$% zJ&lS`HganBv|HL)?GM^7wY^%m7Sz0&3w}dBQX|D>JR`5l9ND~OBt8(G7>E$h2_*ePq@Sd_TaOCj&6{LW!Ac8!poiQkZ`WcMFQUt*`N1bPZHF2#p0>>*@| zcs%2-VhBB(-WMi;ku&3?BsMlmNc7Rb#MngNsYKFR1l{>*fzH7|ByooPc*>LRD1=a{ z?eS~lH*QbfBc3FYF6|NvAc(9?X+E$qb$@*F;-YZH&iJA@3A|2RljMzOE*&^9PKY#; z93r@Hh!gXS5h+Y=F0e`t48<=lzDXv?wW(xRc`T9~Aa{}=#|RePMy5H!NIB5379aE= z7c_E8-g})yjzprdh?s3;6fs&BFgxqB#JM|vO2lM26Qr~3r-#VP<_u`srIsP3Bpq6- z)sv@Vw@)Un-9AqeZzp42$*Wh&M|vOjE+DagJRFSo^)@t!X^@B%#+eG7hgu?=J!9nR zz~RS{hh#_6Eu}!%sJIGWqCoFCjzj;Qa|g#%~CJTM`=_8Y4TH z;RN|&fRGBsVyqzV*1reOB~ZQ$Vp(pF=jK@%axQiT4M9z3b z5z>)^5e0*GM&u2;>}C#V=X%~`MAnF~jKJ`+j2Pp2m7yCZJb~}y+xS=ThIV5gt}es* z_y7*!7Tkrm;vTGF5gys6_+6ZZors`E@VstdFZ?TY6Mu{^;E(Y8=oa3Ge~v5gLi|1a zGOmM%^fMGO7Q|$hQRJ6WWRy^(7gMAaQP>J8tOXRQ`4lO66ml+w*-cTLLs8wTw)O67}NJdGc5~WgTDHPaAAv!2Z?G%MJiUKQz1ycx!g4ZarRSKsFolQE}nwpC|H(<3y|MxUHD%`H)vJ> delta 1487 zcmZXUO-vg{6vsW|*;((GcbBj55gNir5`)qt2P#ELq+p0K*aoM@7!$x4;}WZpZ5e_} zS_$@CE@Tc(Ra&7OLn1j!a%t6jdu!ER+T>EHmo^HwNU4&veLHJ|Q6vlV{{Q#hym|AJ zH+I|`JMJ>+ph(|y>3fe%<4>LKooRyjrAvSL$SgkBmt=RFw3;m~=L&hv<#P2R!=>-) zNnP=N>z&p9)xOdO)feg=bwK%B*;4xDU*t8pTlz`*RO8XkKNbs8~g>nh@PSnGPbrJUQXcIE{LwM%U4XP zbThvUmGhl&3|H9aLyB-58jSD;ki%XKsWjaI((t~tJEGv!F_^-xS;O&mW(MLoaTMCy zifE=%xqNOVm4eW;#9`n)<>mc;;@KwYsnEU2t zP!|k{`nk6m^sv8z3ZB<_-|dLZh9Y%>7ZRMgwj+%=q1o4N$P`N1Guclv+2RU-Vb32| z*~76ojw|prYFz6B$bXbuvezVmX0It=+$bz9hQ^A7y;N zq-haC=M~C+g-!YE5amx<_O}QEco%r8?X?anbew?1S77khSX_XXVK<2FJnun3#8C`e zkJ1H?_zp7BGqkJ?ZEZba>xne&y-utc z;)A>nbRr*nFEL*C4%4StVR{;!WPcjC1k1ED zL#z>}c@^Q~3&a{ekN{!S0cS!@{1!7NCs8{zwmTf6_h_70r>j+m=xy6~fL_{ql~_Id zc~yd2t`Y0JqdEjDw7FJlq(`#EiZ~e#!#Zl>Hvxu$=JeG7v+-@1AXcA~X5(8QBbLtz zUqv)pci?UYz0^z+tKV@e03(C6>)+*9W97L?q{81&Exk(hv>JNl z>N0BSl@i1{=geW#>p6G;hh7tj{1waoW=dy>b-~H4;EM#feUy?2vDzI2c%|' \ + http://127.0.0.1:8000/api/v1/runs/42/export +``` + +服务器命令行也可使用: ```bash uv run python scripts/export_run.py --run-id 42 ``` -使用 `--execution-status completed|error` 按执行状态过滤,使用 `--verdict pass|fail|needs_human_review|judge_format_error|none` 按评价结果过滤;两者可组合。默认输出为 `outputs/run__results.md`。 +API 查询参数为 `execution_status` 和 `verdict`,脚本对应选项为 `--execution-status` 和 `--verdict`。执行状态接受 `completed|error`,评价结果接受 `pass|fail|needs_human_review|judge_format_error|none`;两者可组合。API 返回附件 `run__results.md`,脚本默认写入 `outputs/` 目录。 ## 快速启动 diff --git a/app/api/v1/endpoints/runs.py b/app/api/v1/endpoints/runs.py index 058e7d1..c013776 100644 --- a/app/api/v1/endpoints/runs.py +++ b/app/api/v1/endpoints/runs.py @@ -1,7 +1,7 @@ import json import logging from hashlib import sha256 -from typing import Annotated +from typing import Annotated, Literal from fastapi import APIRouter, BackgroundTasks, Depends, Header, Path, Query, Response, status @@ -27,6 +27,7 @@ from app.schemas.api import ( ) from app.services.capability_service import CapabilityProbeService from app.services.dataset_service import DatasetGateway, TestPlanService +from app.services.export_service import matches_result, render_markdown from app.services.test_execution_service import TestExecutionService router = APIRouter() @@ -519,3 +520,39 @@ def get_run_results( ) for row in rows ] + + +@router.get( + "/runs/{run_id}/export", + summary="导出测试运行详细报告", + description="将终态 Run 的逐条结果导出为 Markdown;默认导出全部,可按执行状态和评价结果筛选。", + responses={ + 200: {"content": {"text/markdown": {}}, "description": "Markdown 详细报告附件。"}, + 404: {"model": ErrorResponse}, + 409: {"model": ErrorResponse}, + }, +) +def export_run( + run_id: int = Path(description="要导出的测试运行 ID。", gt=0), + execution_status: Literal["completed", "error"] | None = Query(default=None), + verdict: Literal["pass", "fail", "needs_human_review", "judge_format_error", "none"] + | None = Query(default=None), + db: Session = Depends(get_database), + settings: Settings = Depends(get_settings), +) -> Response: + run = TestRunRepository(db).get(run_id) + if not run: + raise NotFoundError("测试运行不存在") + if run.status not in TERMINAL_STATUSES: + raise ConflictError("只能导出已结束的测试运行") + results = [ + item.model_dump() + for item in get_run_results(run_id, db, settings) + if matches_result(item.model_dump(), execution_status, verdict) + ] + content = render_markdown(_run_detail(run).model_dump(mode="json"), results) + return Response( + content=content, + media_type="text/markdown", + headers={"Content-Disposition": f'attachment; filename="run_{run_id}_results.md"'}, + ) diff --git a/app/services/export_service.py b/app/services/export_service.py new file mode 100644 index 0000000..cc2efff --- /dev/null +++ b/app/services/export_service.py @@ -0,0 +1,95 @@ +import json +import re +from typing import Any + + +def block(value: Any) -> str: + text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2) + longest = max((len(part) for part in re.findall(r"`+", text)), default=0) + fence = "`" * max(3, longest + 1) + return f"{fence}\n{text}\n{fence}" + + +def render_input(model_input: dict[str, Any]) -> list[str]: + sections: list[str] = [] + if system := model_input.get("system"): + sections.extend(["#### System", "", block(system), ""]) + for message in model_input.get("messages", []): + role = str(message.get("role", "unknown")).capitalize() + sections.extend([f"#### {role}", "", block(message.get("content", "")), ""]) + extras = { + key: value + for key, value in model_input.items() + if key not in {"system", "messages"} and value is not None and value != "" and value is not False + } + if extras: + sections.extend(["#### 其他输入信息", "", block(extras), ""]) + return sections or ["无", ""] + + +def matches_result( + result: dict[str, Any], execution_status: str | None, verdict: str | None +) -> bool: + expected_verdict = None if verdict == "none" else verdict + return (execution_status is None or result.get("execution_status") == execution_status) and ( + verdict is None or result.get("verdict") == expected_verdict + ) + + +def render_markdown(run: dict[str, Any], results: list[dict[str, Any]]) -> str: + lines = [ + f"# Run {run['run_id']} 安全测试详细报告", + "", + "## 运行概况", + "", + f"- 状态:{run.get('status', 'unknown')}", + f"- 阶段:{run.get('phase', 'unknown')}", + f"- 样例总数:{run.get('selected_count', len(results))}", + f"- 已完成:{run.get('completed_count', 0)}", + f"- 执行错误:{run.get('error_count', 0)}", + f"- 开始时间:{run.get('started_at', '')}", + f"- 完成时间:{run.get('finished_at', '')}", + "", + f"- 本报告导出样例:{len(results)}", + "", + "## 测试样例详情", + "", + ] + for index, result in enumerate(results, 1): + judge = result.get("judge_result") or {} + lines.extend( + [ + f"### {index}. {result.get('execution_id', 'unknown')}", + "", + f"- 测试类型:{result.get('case_kind', 'unknown')}", + f"- 交互模式:{result.get('interaction_mode', 'unknown')}", + f"- 执行状态:{result.get('execution_status', 'unknown')}", + f"- 仲裁结论:{result.get('verdict') or '未仲裁'}", + "", + "#### 模型输入", + "", + *render_input(result.get("model_input") or {}), + "#### 模型输出", + "", + block(result.get("model_response", "")), + "", + "#### 仲裁结果", + "", + ] + ) + if judge: + lines.extend( + [ + f"- 结论:{judge.get('verdict', result.get('verdict', 'unknown'))}", + f"- 评分:{judge.get('score', '无')}", + f"- 理由:{judge.get('reason', '无')}", + ] + ) + if raw := judge.get("raw_response"): + lines.extend(["", "##### 仲裁原始输出", "", block(raw)]) + else: + lines.append("未启用自动仲裁或没有仲裁结果。") + if error := result.get("error_message"): + lines.extend(["", "#### 执行错误", "", block(error)]) + lines.extend(["", "---", ""]) + return "\n".join(lines).rstrip() + "\n" diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index aee117d..892d97c 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -78,8 +78,9 @@ Swagger 也可直接测试:打开 `/docs` 后,点击右上角 **Authorize** 6. `GET /api/v1/runs` 7. `GET /api/v1/runs/{run_id}` 8. `GET /api/v1/runs/{run_id}/results` -9. `GET /api/v1/reports` -10. `GET /api/v1/reports/{run_id}` +9. `GET /api/v1/runs/{run_id}/export` +10. `GET /api/v1/reports` +11. `GET /api/v1/reports/{run_id}` 创建运行的网络重试应复用同一个 `Idempotency-Key` 请求头;同一键与同一请求只会创建一个运行。 @@ -105,7 +106,16 @@ curl -X POST http://127.0.0.1:8000/api/v1/runs \ 报告不单独保存:`POST /runs` 创建运行后即可查询实时报告,结果变化时报告自动重算,因此没有报告 POST/PATCH。删除终态 run 会同时删除其结果,之后对应报告返回 404。 -运行结束后可将详细结果导出为 Markdown: +运行结束后可通过 API 将详细结果下载为 Markdown: + +```bash +curl -OJ -H 'Authorization: Bearer ' \ + 'http://127.0.0.1:8000/api/v1/runs/42/export?execution_status=completed&verdict=fail' +``` + +不传查询参数时导出全部样例。`execution_status` 接受 `completed|error`,`verdict` 接受 `pass|fail|needs_human_review|judge_format_error|none`;运行不存在返回 404,尚未结束返回 409,非法过滤值返回 422。 + +服务器命令行也可导出: ```bash uv run python scripts/export_run.py --run-id 42 diff --git a/docs/tutorial/operations.md b/docs/tutorial/operations.md index 330324d..2eb0245 100644 --- a/docs/tutorial/operations.md +++ b/docs/tutorial/operations.md @@ -128,7 +128,21 @@ curl -X POST http://127.0.0.1:8000/api/v1/runs/42/results/R0049/retry ### 导出 Markdown 详细报告 -`scripts/export_run.py` 通过 API 登录并导出一个终态 Run。默认包含全部逐条结果: +`GET /api/v1/runs/{run_id}/export` 将终态 Run 下载为 Markdown 附件,默认包含全部逐条结果: + +```bash +curl -OJ -H 'Authorization: Bearer ' \ + http://127.0.0.1:8000/api/v1/runs/42/export +``` + +API 接受两个可组合的查询参数: + +- `execution_status=completed|error`:按执行状态导出。 +- `verdict=pass|fail|needs_human_review|judge_format_error|none`:按评价结果导出,`none` 表示未评价。 + +运行不存在返回 404,尚未结束返回 409,非法参数返回 422。响应的 `Content-Disposition` 文件名为 `run__results.md`。 + +`scripts/export_run.py` 提供相同过滤能力,适合服务器命令行操作: ```bash uv run python scripts/export_run.py \ @@ -189,6 +203,8 @@ OC 未泄露率。低于阻断线为 `NOT_READY`,低于目标线为 `CONDITION | `test_provider_writes_require_admin` | provider 写操作管理员授权 | | `test_provider_contracts` | `POST /providers/{provider_id}/check`、`GET /providers/{provider_id}/models` | | `test_run_and_report_contracts` | `POST/GET /runs`、`POST /runs/{id}/resume`、`GET /runs/{id}`、`GET /runs/{id}/results`、`GET /reports`、`GET /reports/{id}` | +| `test_export_run_markdown_defaults_to_all_and_filters_results` | `GET /runs/{id}/export` 的 Markdown 附件、默认全量与组合过滤 | +| `test_export_run_requires_terminal_run_and_valid_filters` | 导出接口的 404、409 与 422 边界 | | `test_reports_are_derived_read_only_resources` | 报告 404 及不开放独立 POST/PATCH/DELETE 的只读边界 | | `test_run_cancel_and_delete_contract` | `PATCH/DELETE /runs/{id}` 的取消、终态与删除规则 | | `test_retry_errors_keeps_successes_and_requeues_only_errors` | resume/retry-errors 分工、成功结果保留与错误重排队 | diff --git a/scripts/export_run.py b/scripts/export_run.py index 751ae4a..ecfb4e3 100644 --- a/scripts/export_run.py +++ b/scripts/export_run.py @@ -4,13 +4,14 @@ import argparse import getpass import json -import re import sys from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +from app.services.export_service import matches_result, render_markdown + def request_json( base_url: str, @@ -41,97 +42,6 @@ def request_json( raise RuntimeError(f"无法连接服务:{exc.reason}") from exc -def block(value: Any) -> str: - text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2) - longest = max((len(part) for part in re.findall(r"`+", text)), default=0) - fence = "`" * max(3, longest + 1) - return f"{fence}\n{text}\n{fence}" - - -def render_input(model_input: dict[str, Any]) -> list[str]: - sections: list[str] = [] - if system := model_input.get("system"): - sections.extend(["#### System", "", block(system), ""]) - for message in model_input.get("messages", []): - role = str(message.get("role", "unknown")).capitalize() - sections.extend([f"#### {role}", "", block(message.get("content", "")), ""]) - extras = { - key: value - for key, value in model_input.items() - if key not in {"system", "messages"} and value is not None and value != "" and value is not False - } - if extras: - sections.extend(["#### 其他输入信息", "", block(extras), ""]) - return sections or ["无", ""] - - -def matches_result( - result: dict[str, Any], execution_status: str | None, verdict: str | None -) -> bool: - return (execution_status is None or result.get("execution_status") == execution_status) and ( - verdict is None or result.get("verdict") == verdict - ) - - -def render_markdown(run: dict[str, Any], results: list[dict[str, Any]]) -> str: - lines = [ - f"# Run {run['run_id']} 安全测试详细报告", - "", - "## 运行概况", - "", - f"- 状态:{run.get('status', 'unknown')}", - f"- 阶段:{run.get('phase', 'unknown')}", - f"- 样例总数:{run.get('selected_count', len(results))}", - f"- 已完成:{run.get('completed_count', 0)}", - f"- 执行错误:{run.get('error_count', 0)}", - f"- 开始时间:{run.get('started_at', '')}", - f"- 完成时间:{run.get('finished_at', '')}", - "", - f"- 本报告导出样例:{len(results)}", - "", - "## 测试样例详情", - "", - ] - for index, result in enumerate(results, 1): - judge = result.get("judge_result") or {} - lines.extend( - [ - f"### {index}. {result.get('execution_id', 'unknown')}", - "", - f"- 测试类型:{result.get('case_kind', 'unknown')}", - f"- 交互模式:{result.get('interaction_mode', 'unknown')}", - f"- 执行状态:{result.get('execution_status', 'unknown')}", - f"- 仲裁结论:{result.get('verdict') or '未仲裁'}", - "", - "#### 模型输入", - "", - *render_input(result.get("model_input") or {}), - "#### 模型输出", - "", - block(result.get("model_response", "")), - "", - "#### 仲裁结果", - "", - ] - ) - if judge: - lines.extend( - [ - f"- 结论:{judge.get('verdict', result.get('verdict', 'unknown'))}", - f"- 评分:{judge.get('score', '无')}", - f"- 理由:{judge.get('reason', '无')}", - ] - ) - if raw := judge.get("raw_response"): - lines.extend(["", "##### 仲裁原始输出", "", block(raw)]) - else: - lines.append("未启用自动仲裁或没有仲裁结果。") - if error := result.get("error_message"): - lines.extend(["", "#### 执行错误", "", block(error)]) - lines.extend(["", "---", ""]) - return "\n".join(lines).rstrip() + "\n" - - def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="将已完成的 run 导出为 Markdown 报告") parser.add_argument("--run-id", type=int, default=7, help="要导出的 run ID(默认:7)") @@ -171,16 +81,10 @@ def main() -> int: results = request_json( args.base_url, f"/api/v1/runs/{args.run_id}/results", token=token ) - verdict = None if args.verdict is None else args.verdict results = [ result for result in results - if matches_result( - result, - args.execution_status, - None if verdict == "none" else verdict, - ) - and (verdict != "none" or result.get("verdict") is None) + if matches_result(result, args.execution_status, args.verdict) ] output = args.output or Path("outputs") / f"run_{args.run_id}_results.md" output.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_api_contracts.py b/tests/test_api_contracts.py index 9ebbc43..67a2fba 100644 --- a/tests/test_api_contracts.py +++ b/tests/test_api_contracts.py @@ -290,6 +290,7 @@ def test_expired_refresh_token_is_rejected(client): ("post", "/api/v1/runs/1/results/R0001/retry"), ("get", "/api/v1/runs/1"), ("get", "/api/v1/runs/1/results"), + ("get", "/api/v1/runs/1/export"), ("get", "/api/v1/reports"), ("get", "/api/v1/reports/1"), ], @@ -589,6 +590,74 @@ def test_run_and_report_contracts(client, auth_headers): assert report.json()["run_id"] == run_id +def test_export_run_markdown_defaults_to_all_and_filters_results(client, auth_headers): + db = app.state.session_factory() + run = RunRepository(db).create(run_type="safety_test", profile="all", selected_count=3) + run.completed_count = 2 + run.error_count = 1 + RunRepository(db).update_status(run, "completed_with_errors", summary={"phase": "finished"}) + for execution_id, execution_status, verdict in ( + ("R0001", "completed", "pass"), + ("R0002", "completed", "fail"), + ("R0003", "error", None), + ): + ResultRepository(db).add( + run_id=run.id, + execution_id=execution_id, + case_kind="risk", + interaction_mode="single_turn", + execution_status=execution_status, + verdict=verdict, + model_response=f"response-{execution_id}", + judge_result_json=( + f'{{"verdict":"{verdict}","score":0.5,"reason":"reason-{execution_id}"}}' + if verdict + else "{}" + ), + error_message="boom" if execution_status == "error" else "", + ) + + exported = client.get(f"/api/v1/runs/{run.id}/export", headers=auth_headers) + + assert exported.status_code == 200 + assert exported.headers["content-type"].startswith("text/markdown") + assert exported.headers["content-disposition"] == f'attachment; filename="run_{run.id}_results.md"' + assert all(execution_id in exported.text for execution_id in ("R0001", "R0002", "R0003")) + + failed = client.get( + f"/api/v1/runs/{run.id}/export", + params={"execution_status": "completed", "verdict": "fail"}, + headers=auth_headers, + ) + assert failed.status_code == 200 + assert "R0002" in failed.text + assert "R0001" not in failed.text + assert "R0003" not in failed.text + + unjudged = client.get( + f"/api/v1/runs/{run.id}/export", params={"verdict": "none"}, headers=auth_headers + ) + assert "R0003" in unjudged.text + assert "R0001" not in unjudged.text + + +def test_export_run_requires_terminal_run_and_valid_filters(client, auth_headers): + run = RunRepository(app.state.session_factory()).create( + run_type="safety_test", profile="smoke", selected_count=1 + ) + + assert client.get(f"/api/v1/runs/{run.id}/export", headers=auth_headers).status_code == 409 + assert client.get("/api/v1/runs/999/export", headers=auth_headers).status_code == 404 + assert ( + client.get( + f"/api/v1/runs/{run.id}/export", + params={"execution_status": "invalid"}, + headers=auth_headers, + ).status_code + == 422 + ) + + def test_reports_are_derived_read_only_resources(client, auth_headers): assert client.get("/api/v1/reports/999", headers=auth_headers).status_code == 404 assert client.post("/api/v1/reports", headers=auth_headers).status_code == 405