feat(v2): 纠偏第四批——会议结果录入与改进发布接真实知识发布入口
- api/prescreen.py:transition_improvement 注入 _knowledge_publisher, release 委托 knowledge_cards.publish → component_registry.release; 按 proposed_revision_ref 以「组件id@版本」精确解析草稿(不取"最新草稿", 否则会出现"审核的是 A、发布的是 B");客户端自报 released_ref 一律 422; 非 knowledge 目标 409 诚实拒绝;发布与候选状态同一事务,失败保留旧版。 - TrajectoryPanel:新增会议结果录入(追加式更正走 supersedes_id; 未匹配单列且不计入分母)。 - ImprovementPanel(新增):偏差→候选→两道评测门→独立审核→发布; 请求体不含版本号,无自动上线,不自批。 - 界面并入「轨迹复盘」,不新增第四个导航入口(AC-53)。 - 守卫:新增 scripts/test_improvement_panel.cjs(verify.py 13→14 项); test_module_shell_ui.cjs 的「只读」断言改为分别锁死「回放只读」与「结果只可追加」。 - 测试:新增 backend/test_improvement_release.py(AC-57 端到端 + 三条反例); test_improvements.py 补「自报版本号被拒」。 - 文档:17 第四批交付记录、15 §7 交接、03 §9.4 发布事务边界。 验证:scripts/verify.py 14/14 PASS(backend-unit 572 项),证据 test-evidence/20261001-013517-3e15bda/;scripts/xingbang_batch4_smoke.py 对真实服务器实跑通过(发布指针实测移到新版本)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+66
-12
@@ -629,26 +629,80 @@ def propose_improvement(project_id: str, payload: ImprovementInput, request: Req
|
||||
return run(request, action)
|
||||
|
||||
|
||||
def _revision_draft(db, ref: dict) -> dict:
|
||||
"""把候选声明的 `proposed_revision_ref` 解析成**那一份**草稿。
|
||||
|
||||
坚持按 `组件id@版本` 精确定位:只按组件 id 取"当前草稿"会解析到另一个修订
|
||||
(`revise()` 升版本后旧草稿仍在),于是出现"审核的是 A、发布的是 B"。
|
||||
解析不到就报未完成,**不退回猜一个草稿**。
|
||||
"""
|
||||
ref = ref or {}
|
||||
component_id = str(ref.get("id") or "").strip()
|
||||
version = str(ref.get("version") or "").strip()
|
||||
if not component_id or not version:
|
||||
raise improvements.NotValidated("候选必须声明要发布的组件版本(proposed_revision_ref)")
|
||||
draft_id = component_id if "@" in component_id else f"{component_id}@{version}"
|
||||
try:
|
||||
return knowledge_cards.current_draft(db, draft_id)
|
||||
except KeyError as exc:
|
||||
raise improvements.NotValidated(
|
||||
f"候选指向的草稿 {draft_id} 不存在;请先形成草稿修订再发布") from exc
|
||||
|
||||
|
||||
def _knowledge_publisher(db, actor: str, preview: bool):
|
||||
"""把改进候选的发布接到**真实知识发布入口**(BR-51 / AC-57)。
|
||||
|
||||
走 `knowledge_cards.publish` → `component_registry.release`,因此"自批拒绝、
|
||||
隔离评测门禁、依赖可解析"三道既有保证原样生效,发布成功才覆盖 `release` 指针、
|
||||
后续运行由 `freeze() → resolve()` 取到新版本。候选只是**指向**这次真实发布。
|
||||
|
||||
`db` 必须是调用方同一个连接:另开连接会让"写组件版本"与"写候选状态"分裂成
|
||||
两个事务,出现"版本已发布、候选没标记"的裂口。
|
||||
publisher 抛异常时 `improvements.transition` 不写候选状态(发布失败保留旧版)。
|
||||
"""
|
||||
def publish(candidate: dict) -> dict:
|
||||
if candidate.get("target") != "knowledge":
|
||||
raise improvements.NotValidated(
|
||||
f"target={candidate.get('target')} 尚无真实发布入口;不得用改状态冒充发布")
|
||||
draft = _revision_draft(db, candidate.get("proposed_revision_ref"))
|
||||
try:
|
||||
published = knowledge_cards.publish(db, draft["id"], actor, preview)
|
||||
except store.Conflict as exc:
|
||||
# 评测/依赖门禁未过:统一按"未完成门禁"回报,界面只需处理一个码(E-24)
|
||||
raise improvements.NotValidated(str(exc)) from exc
|
||||
return {"id": published["component_id"], "version": published["version"]}
|
||||
|
||||
return publish
|
||||
|
||||
|
||||
@router.post("/improvements/{candidate_id}/transitions")
|
||||
def transition_improvement(candidate_id: str, payload: TransitionInput, request: Request):
|
||||
"""候选状态机。泄漏按 E-11 拒绝,未通过对照/审核按 E-24 拒绝。"""
|
||||
"""候选状态机。泄漏按 E-11 拒绝,未通过对照/审核按 E-24 拒绝。
|
||||
|
||||
`release` **不接受调用方自报的版本号**:版本由服务端调用真实知识发布入口产生
|
||||
(BR-51 / AC-57)。只改 `released` 状态或返回一个版本号都不算发布,因此带了
|
||||
`released_ref` 的请求一律 422 拒绝,而不是静默忽略它。
|
||||
"""
|
||||
def action():
|
||||
if payload.released_ref is not None:
|
||||
raise ValueError("发布版本由服务端实际发布产生,不接受客户端提交的 released_ref")
|
||||
with _db() as db:
|
||||
probe = store.get(db, improvements.KIND, candidate_id)
|
||||
blocked = scope(request, probe["project_id"])
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
try:
|
||||
with _db() as db:
|
||||
blocked = scope(request, probe["project_id"])
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
try:
|
||||
return improvements.transition(
|
||||
db, candidate_id, payload.action, _actor(request),
|
||||
payload.expected_revision, evaluation=payload.evaluation,
|
||||
contrast=payload.contrast, released_ref=payload.released_ref,
|
||||
reason=payload.reason)
|
||||
except improvements.LeakageDetected as exc:
|
||||
return _envelope(request, ErrorCode.EVALUATION_CONTAMINATED, str(exc))
|
||||
except improvements.NotValidated as exc:
|
||||
return _envelope(request, ErrorCode.IMPROVEMENT_NOT_VALIDATED, str(exc))
|
||||
contrast=payload.contrast, reason=payload.reason,
|
||||
publisher=_knowledge_publisher(
|
||||
db, _actor(request),
|
||||
request.state.identity.get("preview", False)))
|
||||
except improvements.LeakageDetected as exc:
|
||||
return _envelope(request, ErrorCode.EVALUATION_CONTAMINATED, str(exc))
|
||||
except improvements.NotValidated as exc:
|
||||
return _envelope(request, ErrorCode.IMPROVEMENT_NOT_VALIDATED, str(exc))
|
||||
|
||||
return run(request, action)
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""纠偏第四批 · 改进发布接到真实知识发布入口(BR-51 / AC-57)。
|
||||
|
||||
审计(audit.json)确认的偏差模式之一是「只改 `released` 状态或返回一个版本号」就当发布成功。
|
||||
本测试走**真实执行链**证明发布真的生效:
|
||||
|
||||
卡片草稿 → 隔离评测 → 独立审核发布 v1.0.0 → 真实运行(冻结 v1.0.0)
|
||||
→ 提出改进候选(指向 v1.1.0 草稿)→ 旧/新对照 → 提交 → 他人审核
|
||||
→ release(**不提交任何版本号**)→ 真实发布 v1.1.0,覆盖 release 指针
|
||||
→ 再跑一次:新运行用 v1.1.0;回放第一次运行仍是 v1.0.0
|
||||
|
||||
反例同样锁死:客户端自报 `released_ref` 被 422 拒绝;候选指向未经评测的草稿时
|
||||
发布被 409 拒绝且**不产生**新版本、不改指针;非知识类目标没有真实入口,诚实拒绝而非伪造。
|
||||
|
||||
不用注入记录;临时库;不触碰真实数据根。
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api import prescreen as prescreen_api
|
||||
from api import project_assistant as api
|
||||
from api import risk_agent_config as catalog
|
||||
from services import assistant_store as store
|
||||
from services import improvements, knowledge_cards, trajectory
|
||||
from services.assistant_worker import DurableWorker
|
||||
from services.project_assistant import ProjectAssistantService
|
||||
|
||||
PROJECT = "DEMO-B"
|
||||
BASE = "/api/xingbang/risk-agent/project-assistant"
|
||||
PRESCREEN = BASE + "/prescreen"
|
||||
MANAGER = {"X-Xingbang-Demo-Role": "manager"}
|
||||
REVIEWER = {"X-Xingbang-Demo-Role": "reviewer"}
|
||||
|
||||
CARD_ID = "skill.history.repayment"
|
||||
V1 = "1.0.0"
|
||||
V2 = "1.1.0"
|
||||
CARD_CODE = "history.repayment.dependency"
|
||||
|
||||
# 自定义 Skill 的发布门禁要求独立测试样本(component_evaluation 的既有规则)
|
||||
TEST_CASES = [
|
||||
{"name": "缺证样本", "values": {"financial.operating_cashflow": None},
|
||||
"expected_outputs": {}, "expected_checks": {CARD_CODE: "unknown"}},
|
||||
{"name": "已提供样本", "values": {"financial.operating_cashflow": "1200"},
|
||||
"expected_outputs": {}, "expected_checks": {CARD_CODE: "pass"}},
|
||||
]
|
||||
|
||||
BASELINE = {"id": "assistant.current", "version": "1.0.0"}
|
||||
CANDIDATE = {"id": "prescreen.v2", "version": "0.2.0"}
|
||||
|
||||
|
||||
def sample(sample_id, *, baseline_pass=False, candidate_pass=True,
|
||||
predicted="2026-09-29T01:00:00+00:00", revealed="2026-09-29T03:00:00+00:00"):
|
||||
return {"id": sample_id, "split": "dev", "observed": True,
|
||||
"baseline_pass": baseline_pass, "candidate_pass": candidate_pass,
|
||||
"predicted_at": predicted, "label_revealed_at": revealed,
|
||||
"source_in_dev": False}
|
||||
|
||||
|
||||
def improvement_evaluation():
|
||||
"""一次独立、无泄漏、结论为 improved 的旧/新对照。"""
|
||||
return {"baseline_ref": BASELINE, "candidate_ref": CANDIDATE,
|
||||
"dataset_ref": "fixtures/cases/manifest.json", "split_ref": "split.v1",
|
||||
"independent": True,
|
||||
"samples": [sample("S-1", baseline_pass=False),
|
||||
sample("S-2", baseline_pass=True)]}
|
||||
|
||||
|
||||
class ImprovementReleaseTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.path = Path(self.temp.name)
|
||||
self.service = ProjectAssistantService(self.path / "assistant.sqlite3")
|
||||
self.patches = [
|
||||
patch.object(api, "service", lambda: self.service),
|
||||
patch.object(prescreen_api, "_service", lambda: self.service),
|
||||
patch.object(catalog, "DB_PATH", self.path / "catalog.sqlite3"),
|
||||
patch.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0",
|
||||
"XINGBANG_LOCAL_MODEL_URL": "", "XINGBANG_DEMO_ROLE_LOGIN": "1"}),
|
||||
]
|
||||
for item in self.patches:
|
||||
item.start()
|
||||
self.worker = DurableWorker(self.service)
|
||||
app = FastAPI()
|
||||
app.include_router(api.router)
|
||||
app.include_router(prescreen_api.router)
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self):
|
||||
self.worker.stop()
|
||||
self.client.close()
|
||||
for item in reversed(self.patches):
|
||||
item.stop()
|
||||
self.temp.cleanup()
|
||||
|
||||
# ------------------------------------------------------------ 工具
|
||||
def call(self, method, path, body=None, headers=MANAGER, root=PRESCREEN):
|
||||
return self.client.request(method, root + path, json=body, headers=headers)
|
||||
|
||||
def draft_card(self, version):
|
||||
"""人工新增一个该组件的新版本草稿(不走模型,故不受模型未配置影响)。"""
|
||||
with self.service.connect() as db:
|
||||
return knowledge_cards.propose(db, {
|
||||
"id": CARD_ID, "version": version, "kind": "skill",
|
||||
"name": "核查回款集中依赖", "engine": "required",
|
||||
"inputs": ["financial.operating_cashflow"],
|
||||
"code": CARD_CODE, "title": "核查回款集中依赖",
|
||||
"test_cases": TEST_CASES,
|
||||
}, "demo-manager", PROJECT, origin="history_derived", source_event_ids=["event-1"])
|
||||
|
||||
def publish_v1_and_run_once(self):
|
||||
"""发布 v1.0.0、装配方案并真实跑一次,返回(已发布方案 id, 第一次运行)。"""
|
||||
self.draft_card(V1)
|
||||
published = self.call("POST", f"/knowledge-candidates/{CARD_ID}/transitions",
|
||||
{"action": "evaluate"}, REVIEWER)
|
||||
self.assertEqual(published.status_code, 200, published.text)
|
||||
published = self.call("POST", f"/knowledge-candidates/{CARD_ID}/transitions",
|
||||
{"action": "publish"}, headers=REVIEWER)
|
||||
self.assertEqual(published.status_code, 200, published.text)
|
||||
self.assertEqual(published.json()["version"], V1)
|
||||
|
||||
plan = self.call("POST", f"/projects/{PROJECT}/review-plans", {
|
||||
"name": "含历史经验卡的预审方案",
|
||||
"skills": ["corporate.consistency", "corporate.cashflow", "corporate.assets",
|
||||
"corporate.coverage", CARD_ID],
|
||||
"rules": ["demo.coverage"], "template": "template.ai-pre-review",
|
||||
"budget": {"timeout_seconds": 180, "max_tool_calls": 18},
|
||||
"audience": ["manager"], "run_kind": "pre_review"})
|
||||
self.assertEqual(plan.status_code, 201, plan.text)
|
||||
plan_id = plan.json()["component_id"]
|
||||
self.assertEqual(self.call("POST", f"/review-plans/{plan_id}/transitions",
|
||||
{"action": "evaluate"}, REVIEWER).status_code, 200)
|
||||
self.assertEqual(self.call("POST", f"/review-plans/{plan_id}/transitions",
|
||||
{"action": "publish"}, REVIEWER).status_code, 200)
|
||||
return plan_id, self.run_with(plan_id)
|
||||
|
||||
def run_with(self, plan_id):
|
||||
queued = self.call("POST", f"/projects/{PROJECT}/runs",
|
||||
{"requirement": "核对回款与现金流", "plan_id": plan_id}, root=BASE)
|
||||
self.assertIn(queued.status_code, (200, 202), queued.text)
|
||||
run_id = queued.json()["id"]
|
||||
self.assertTrue(self.worker.process_next(), "队列里应当有一次待执行运行")
|
||||
with self.service.connect() as db:
|
||||
return store.get(db, "run", run_id)
|
||||
|
||||
@staticmethod
|
||||
def checks_of(run, code):
|
||||
return [check for step in run["steps"] for check in step.get("findings", [])
|
||||
if check.get("code") == code]
|
||||
|
||||
def released_version(self):
|
||||
with self.service.connect() as db:
|
||||
return store.get(db, "release", CARD_ID)["version_id"]
|
||||
|
||||
def propose_improvement(self, *, target="knowledge", revision_ref=None, actor=MANAGER,
|
||||
source_run_id=None):
|
||||
body = {"target": target,
|
||||
"target_ref": {"id": CARD_ID, "version": V1},
|
||||
"proposed_revision_ref": revision_ref or {"id": CARD_ID, "version": V2},
|
||||
"change_summary": "把回款集中依赖的核查口径扩到制造业设备直租",
|
||||
"source_run_ids": [source_run_id] if source_run_id else ["run-1"]}
|
||||
created = self.call("POST", f"/projects/{PROJECT}/improvements", body, actor)
|
||||
self.assertEqual(created.status_code, 201, created.text)
|
||||
return created.json()
|
||||
|
||||
def approve(self, candidate, actor=REVIEWER):
|
||||
"""对照评测 → 提交 → 由**另一位**身份通过审核。"""
|
||||
evaluated = self.call("POST", f"/improvements/{candidate['id']}/transitions",
|
||||
{"action": "evaluate", "evaluation": improvement_evaluation()})
|
||||
self.assertEqual(evaluated.status_code, 200, evaluated.text)
|
||||
self.assertEqual(evaluated.json()["status"], "ready", evaluated.text)
|
||||
self.assertEqual(self.call("POST", f"/improvements/{candidate['id']}/transitions",
|
||||
{"action": "submit"}).status_code, 200)
|
||||
approved = self.call("POST", f"/improvements/{candidate['id']}/transitions",
|
||||
{"action": "approve"}, actor)
|
||||
self.assertEqual(approved.status_code, 200, approved.text)
|
||||
self.assertEqual(approved.json()["status"], "approved")
|
||||
return approved.json()
|
||||
|
||||
# ------------------------------------------------------------ AC-57
|
||||
def test_release_publishes_the_real_version_and_old_run_still_replays(self):
|
||||
plan_id, run_old = self.publish_v1_and_run_once()
|
||||
old_refs = {(item["id"], item["version"]) for item in run_old["component_versions"]}
|
||||
self.assertIn((CARD_ID, V1), old_refs, "第一次运行应冻结 v1.0.0")
|
||||
self.assertEqual(self.released_version(), f"{CARD_ID}@{V1}")
|
||||
|
||||
# 1) 新版本草稿 + 隔离评测(发布门禁,与候选的旧/新对照是两道不同的门)
|
||||
self.draft_card(V2)
|
||||
evaluated = self.call("POST", f"/knowledge-candidates/{CARD_ID}/transitions",
|
||||
{"action": "evaluate"}, REVIEWER)
|
||||
self.assertEqual(evaluated.status_code, 200, evaluated.text)
|
||||
|
||||
# 2) 改进候选:对照 → 提交 → 他人审核 → 发布(**请求体不含任何版本号**)
|
||||
candidate = self.propose_improvement(source_run_id=run_old["id"])
|
||||
self.approve(candidate)
|
||||
released = self.call("POST", f"/improvements/{candidate['id']}/transitions",
|
||||
{"action": "release"}, REVIEWER)
|
||||
self.assertEqual(released.status_code, 200, released.text)
|
||||
body = released.json()
|
||||
self.assertEqual(body["status"], "released")
|
||||
# 版本引用由服务端从真实发布结果产生
|
||||
self.assertEqual(body["released_ref"], {"id": CARD_ID, "version": V2})
|
||||
|
||||
# 3) 发布真的生效:release 指针被覆盖,不可变版本已落库
|
||||
self.assertEqual(self.released_version(), f"{CARD_ID}@{V2}")
|
||||
with self.service.connect() as db:
|
||||
versions = {row["id"] for row in store.listing(db, "component_version")}
|
||||
self.assertIn(f"{CARD_ID}@{V2}", versions, "必须真的写入不可变组件版本")
|
||||
|
||||
# 4) 新运行使用新版本(同一个已发布方案,无需重装)
|
||||
run_new = self.run_with(plan_id)
|
||||
new_refs = {(item["id"], item["version"]) for item in run_new["component_versions"]}
|
||||
self.assertIn((CARD_ID, V2), new_refs, "新运行必须取到新版本")
|
||||
new_checks = self.checks_of(run_new, CARD_CODE)
|
||||
self.assertEqual(len(new_checks), 1, "新版本必须真的产生核查项")
|
||||
self.assertEqual(new_checks[0]["basis_ids"], [f"{CARD_ID}@{V2}"])
|
||||
|
||||
# 5) 旧运行仍可回放,且冻结的版本没有被新发布改写
|
||||
with self.service.connect() as db:
|
||||
replayed = trajectory.replay(db, PROJECT, run_old["id"])
|
||||
reread = store.get(db, "run", run_old["id"])
|
||||
step = next(item for item in replayed["steps"]
|
||||
if (item["component_ref"] or {}).get("id") == CARD_ID)
|
||||
self.assertEqual(step["component_ref"]["version"], V1,
|
||||
"旧运行回放必须仍是发布前的版本")
|
||||
self.assertEqual({(item["id"], item["version"]) for item in reread["component_versions"]},
|
||||
old_refs, "旧运行的冻结版本不得被新发布改写")
|
||||
self.assertTrue(replayed["read_only"])
|
||||
|
||||
def test_client_supplied_version_is_rejected_and_changes_nothing(self):
|
||||
"""自报 `released_ref` 不得让候选变成已发布,也不得凭空造出组件版本。"""
|
||||
candidate = self.propose_improvement()
|
||||
self.approve(candidate)
|
||||
|
||||
forged = self.call("POST", f"/improvements/{candidate['id']}/transitions",
|
||||
{"action": "release",
|
||||
"released_ref": {"id": CARD_ID, "version": "9.9.9"}}, REVIEWER)
|
||||
self.assertEqual(forged.status_code, 422, forged.text)
|
||||
self.assertEqual(forged.json()["error"]["code"], "INVALID_REQUEST")
|
||||
|
||||
with self.service.connect() as db:
|
||||
stored = store.get(db, improvements.KIND, candidate["id"])
|
||||
versions = {row["id"] for row in store.listing(db, "component_version")}
|
||||
self.assertEqual(stored["status"], "approved", "拒绝后候选状态不得前进")
|
||||
self.assertIsNone(stored["released_ref"])
|
||||
self.assertNotIn(f"{CARD_ID}@9.9.9", versions)
|
||||
|
||||
def test_release_keeps_the_previous_version_when_the_draft_was_not_evaluated(self):
|
||||
"""未过隔离评测的草稿不得被发布;失败时保留旧版(发布不是"改状态")。"""
|
||||
_, run_old = self.publish_v1_and_run_once()
|
||||
self.assertEqual(self.released_version(), f"{CARD_ID}@{V1}")
|
||||
self.draft_card(V2) # 只建草稿,**不做**隔离评测
|
||||
|
||||
candidate = self.propose_improvement(source_run_id=run_old["id"])
|
||||
self.approve(candidate)
|
||||
refused = self.call("POST", f"/improvements/{candidate['id']}/transitions",
|
||||
{"action": "release"}, REVIEWER)
|
||||
self.assertEqual(refused.status_code, 409, refused.text)
|
||||
self.assertEqual(refused.json()["error"]["code"], "IMPROVEMENT_NOT_VALIDATED")
|
||||
|
||||
self.assertEqual(self.released_version(), f"{CARD_ID}@{V1}", "发布失败必须保留旧版")
|
||||
with self.service.connect() as db:
|
||||
versions = {row["id"] for row in store.listing(db, "component_version")}
|
||||
stored = store.get(db, improvements.KIND, candidate["id"])
|
||||
self.assertNotIn(f"{CARD_ID}@{V2}", versions, "未评测的版本不得落库")
|
||||
self.assertEqual(stored["status"], "approved", "失败时候选状态不得前进")
|
||||
|
||||
def test_unsupported_target_is_refused_instead_of_faked(self):
|
||||
"""题库/编排尚无真实发布入口:诚实拒绝,不用改状态冒充发布。"""
|
||||
candidate = self.propose_improvement(target="question_profile")
|
||||
self.approve(candidate)
|
||||
refused = self.call("POST", f"/improvements/{candidate['id']}/transitions",
|
||||
{"action": "release"}, REVIEWER)
|
||||
self.assertEqual(refused.status_code, 409, refused.text)
|
||||
self.assertIn("尚无真实发布入口", refused.json()["error"]["message"])
|
||||
with self.service.connect() as db:
|
||||
stored = store.get(db, improvements.KIND, candidate["id"])
|
||||
self.assertNotEqual(stored["status"], "released")
|
||||
self.assertIsNone(stored["released_ref"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -222,13 +222,32 @@ class ImprovementApiTests(unittest.TestCase):
|
||||
candidate = created.json()
|
||||
self.assertEqual(candidate["status"], "draft")
|
||||
|
||||
# release 不再接受调用方自报的版本号(BR-51 / AC-57);未过门禁在此先命中
|
||||
blocked = self.client.post(
|
||||
f"{self.base}/improvements/{candidate['id']}/transitions",
|
||||
json={"action": "release",
|
||||
"released_ref": {"id": "card-1", "version": "2.0.0"}}, headers=HEADERS)
|
||||
json={"action": "release"}, headers=HEADERS)
|
||||
self.assertEqual(blocked.status_code, 409)
|
||||
self.assertEqual(blocked.json()["error"]["code"], "IMPROVEMENT_NOT_VALIDATED")
|
||||
|
||||
def test_client_supplied_released_ref_is_rejected(self):
|
||||
"""发布版本必须由服务端从真实发布结果产生,不接受调用方提交。"""
|
||||
created = self.client.post(f"{self.base}/projects/{PROJECT}/improvements",
|
||||
json={"target_ref": {"id": "card-1", "version": "1.0.0"},
|
||||
"proposed_revision_ref": {"id": "card-1", "version": "2.0.0"},
|
||||
"change_summary": "扩适用范围",
|
||||
"source_run_ids": ["run-1"]}, headers=HEADERS).json()
|
||||
response = self.client.post(
|
||||
f"{self.base}/improvements/{created['id']}/transitions",
|
||||
json={"action": "release",
|
||||
"released_ref": {"id": "card-1", "version": "2.0.0"}}, headers=HEADERS)
|
||||
self.assertEqual(response.status_code, 422, response.text)
|
||||
self.assertEqual(response.json()["error"]["code"], "INVALID_REQUEST")
|
||||
# 自报的版本号不得让候选变成"已发布"
|
||||
stored = self.client.get(f"{self.base}/projects/{PROJECT}/improvements",
|
||||
headers=HEADERS).json()["items"][0]
|
||||
self.assertEqual(stored["status"], "draft")
|
||||
self.assertIsNone(stored["released_ref"])
|
||||
|
||||
def test_leakage_is_reported_as_e11(self):
|
||||
created = self.client.post(f"{self.base}/projects/{PROJECT}/improvements",
|
||||
json={"target_ref": {"id": "card-1", "version": "1.0.0"},
|
||||
|
||||
Reference in New Issue
Block a user