mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 23:22:00 +00:00
3293ac0b58
为什么:自由形的骨架就是导入的图,图里派给岗位的人工节点、没登记查询的写入在应用里都要绑人,可蓝图上没有「谁来做」卡片。 第一次发布挡在 app.slot_unbound / app.reconciler_unbound 上,白话叫人「在「谁来做」里给这一步选人」,卡片却不存在;这时 应用还没建,也没法去应用编辑器绑,只能绕开蓝图(05-已知遗留 (d))。 做了什么: - freeform 的 cards 加 people(「可选:只定每一步由谁做;会签、时限、超时在画布上改」)。freeform.people_rows 按 tree.slot_refs 列岗位行(同一个岗位用在几处合成一行,label 用节点标题拼、detail 写全每一处用途,path 是 /people/<i>/who);写入能力没登记 查询时加一行「写入结果不明时谁去核实」(path /writeback/reconciler);发起人、表达式、外部系统回调的步骤列成只读行。 blueprint_out 给出 derived_people,界面照这一份画。 - 存储复用现有路径:岗位绑定存在 spec.people[](id 就是岗位名、只写 who),_common.slot_of / bindings_of 原样生效;核实人存在 /writeback/reconciler。needs_reconciler 对自由形按图里的写入算(with_graph 把蓝图存的图带给模板函数)。 - bp_set 对自由形的 people 只收 who(只收人和组,只由用户定),改会签、时限、超时、加减步骤报 bp.template_mismatch「在画布上 改」。apply_bp_op / blueprint_issues 新增 graph 参数,卡片接口、蓝图检查、试运行、实例化、蓝图助手都把蓝图存的图传下去。 - 检查:派生岗位、核实人没绑报 bp.people_unbound(试运行 warning、发布 error),路径就是那一行,带 slot;图里已经没有的岗位、 卡片上还留着的绑定报 warning,不静默删除。发布单照核实人的办法,去掉同一件事的 app.slot_unbound(slots.<slot>)和 app.reconciler_unbound。 偏离:岗位行不让助手代填(同「结果不明时谁去核实」,派活给谁是人的决定)。 测试:test_publish_plan 新增「财务审批(岗位)+ 写回(没登记查询)」建自由形蓝图一条(两行、不选人时只有 bp.people_unbound、 改会签被拒、选好人后绑到 finance 和核实岗位、发布单不再挡);老的两条按新说法改;test_blueprint_templates 新增 people_rows 的合并、升级岗位、只读行、残留绑定提醒。
845 lines
52 KiB
Python
845 lines
52 KiB
Python
"""一次确认发布:发布单(docs/design/normal-user-apps.md N5、§3.6,包 B2 的 blueprints/publish_plan.py)。
|
|
|
|
- 顺利走完:出单只读(什么都没建)、九步依次做完——存版本(工作流记下出自哪个蓝图)、预检、发布工作流、建定时任务
|
|
(先停用)、建应用(钉住项换成发布的那一版、视图绑定时任务)、发布应用、启用定时任务、授权、跑第一次(按定时任务触发),
|
|
每步留痕、审计写 publish_plan.*,蓝图记「已发布」;
|
|
- 预检挡住时什么都没写:连工作流版本都不存,没有定时任务、没有应用;
|
|
- 流程类岗位没绑人:预检报 app.slot_unbound,白话写「你来定」;
|
|
- 中途失败后接着做:停在那一步、写明原因(工作流已发布,应用还没有);接着做从那一步开始,已完成的按 id 核对、不重做;
|
|
前面建的东西不见了,接着做停下、不另建一份;
|
|
- 受管级要管理员:搭建者出单时问题里写「要找管理员」,执行 403;
|
|
- 图变了拒绝执行(plan.graph_changed);发布的图和最近一次试运行的不同,照实提醒(plan.trial_differs,不拦)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import func, select
|
|
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import AppGrant, AuditEvent, Run, Schedule, Workflow, WorkflowVersion
|
|
from app.db.models_apps import App, AppVersion
|
|
from app.db.models_blueprints import Blueprint
|
|
from app.engine.runner import run_manager
|
|
from app.main import app
|
|
from tests import blueprint_samples as s
|
|
from tests.fakes import auth_env
|
|
|
|
ACTOR = {"X-Actor": "builder1"}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def engine_up():
|
|
await run_manager.setup()
|
|
yield
|
|
await run_manager.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test", headers=ACTOR) as c:
|
|
yield c
|
|
|
|
|
|
async def _count(model) -> int:
|
|
async with SessionLocal() as session:
|
|
return int(await session.scalar(select(func.count()).select_from(model)) or 0)
|
|
|
|
|
|
async def _board_from_run(client, tmp_path, *, access: dict | None = None, cadence: dict | None = None,
|
|
trial: bool = True) -> dict:
|
|
"""问数据的一轮回答(看板图)→ 蓝图(自由形)→ 卡片填好 →(试运行)。"""
|
|
source = await s.register_source(s.make_source(tmp_path))
|
|
run = await run_manager.start(graph=s.board_graph(source), input_payload=s.PERIOD, started_by="builder1",
|
|
run_class="exploratory", origin_kind="chat")
|
|
assert (await s.wait(run.id)).status == "succeeded"
|
|
r = await client.post("/api/blueprints", json={"source": {"kind": "run", "id": run.id}, "scope": "shared",
|
|
"name": f"门店周报-{uuid.uuid4().hex[:4]}"})
|
|
assert r.status_code == 201, r.text
|
|
bp = r.json()
|
|
await s.edit_spec(bp["id"], purpose=s.item("每周一看上周销售额"), cadence=s.item(cadence or s.WEEKLY),
|
|
access=access or {"use": [{"kind": "user", "name": "zhang"}, {"kind": "group", "name": "门店"}],
|
|
"observe": [], "manage": []})
|
|
if trial:
|
|
r = await client.post(f"/api/blueprints/{bp['id']}/trial", json={"inputs": s.PERIOD})
|
|
assert r.status_code == 200, r.text
|
|
assert (await s.wait(r.json()["run_id"])).status == "succeeded"
|
|
else:
|
|
r = await client.post(f"/api/blueprints/{bp['id']}/instantiate")
|
|
assert r.status_code == 200, r.text
|
|
return (await client.get(f"/api/blueprints/{bp['id']}")).json()
|
|
|
|
|
|
def _steps(plan: dict) -> dict:
|
|
return {st["kind"]: st for st in plan["steps"]}
|
|
|
|
|
|
async def test_a_plan_runs_all_nine_steps(client, tmp_path):
|
|
bp = await _board_from_run(client, tmp_path)
|
|
workflows, schedules, apps = await _count(Workflow), await _count(Schedule), await _count(App)
|
|
|
|
r = await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})
|
|
assert r.status_code == 200, r.text
|
|
plan = r.json()
|
|
assert plan["ok"] is True and plan["status"] == "planned", plan["problems"]
|
|
assert [st["kind"] for st in plan["steps"]] == ["save_version", "preflight", "publish_workflow", "create_schedule",
|
|
"create_app", "publish_app", "enable_schedule", "grant",
|
|
"first_run"]
|
|
assert all(st["status"] == "pending" for st in plan["steps"])
|
|
assert plan["options"]["first_run"] is True and plan["graph_hash"] == bp["graph_hash"]
|
|
summary = {x["step"]: x["text"] for x in plan["summary"]}
|
|
assert "每周一 09:00" in summary["create_schedule"] and "zhang" in summary["grant"] and "门店(组)" in summary["grant"]
|
|
assert not [p for p in plan["problems"] if p["level"] == "error"]
|
|
# 出单只读:什么都没建
|
|
assert (await _count(Workflow), await _count(Schedule), await _count(App)) == (workflows, schedules, apps)
|
|
|
|
r = await client.post(f"/api/publish-plans/{plan['id']}/run")
|
|
assert r.status_code == 200, r.text
|
|
done = r.json()
|
|
assert done["status"] == "done", done["steps"]
|
|
steps = _steps(done)
|
|
assert all(st["status"] == "done" for st in steps.values())
|
|
|
|
row = await s.blueprint(bp["id"])
|
|
assert row.status == "published" and row.workflow_id and row.app_id
|
|
async with SessionLocal() as session:
|
|
wf = await session.get(Workflow, row.workflow_id)
|
|
version = (await session.execute(select(WorkflowVersion).where(
|
|
WorkflowVersion.workflow_id == wf.id))).scalars().one()
|
|
schedule = await session.get(Schedule, steps["create_schedule"]["result"]["schedule_id"])
|
|
application = await session.get(App, row.app_id)
|
|
published = (await session.execute(select(AppVersion).where(
|
|
AppVersion.app_id == application.id))).scalars().one()
|
|
grants = {(g.principal_kind, g.principal, g.role) for g in (await session.execute(
|
|
select(AppGrant).where(AppGrant.app_id == application.id))).scalars()}
|
|
audits = [a.action for a in (await session.execute(select(AuditEvent).where(
|
|
AuditEvent.subject_id == plan["id"]).order_by(AuditEvent.at))).scalars()]
|
|
# 存版本:工作流记下出自哪个蓝图、哪个模板、哪次运行;发布的就是蓝图的那张图
|
|
assert wf.origin["blueprint_id"] == bp["id"] and wf.origin["template"] == "freeform@1" and wf.origin["run_id"]
|
|
assert wf.published_version == 1 and version.level == "published" and version.graph_hash == bp["graph_hash"]
|
|
# 定时任务:正式运行、跑这个工作流,应用发布后启用
|
|
assert schedule.workflow_id == wf.id and schedule.run_class == "formal" and schedule.enabled is True
|
|
assert set(schedule.input_template) == {"period", "period_start", "period_end"}
|
|
# 应用:出自这个蓝图,引用发布的那一版,看板视图只看定时任务出的数
|
|
assert application.blueprint_id == bp["id"] and application.published_version == 1
|
|
pin = published.pins[0]
|
|
assert (pin["workflow_id"], pin["version"], pin["graph_hash"]) == (wf.id, 1, bp["graph_hash"])
|
|
assert published.spec["views"]["latest"]["source"]["from"] == f"schedule:{schedule.id}"
|
|
assert grants == {("user", "zhang", "use"), ("group", "门店", "use")}
|
|
assert audits[0] == "publish_plan.plan" and audits[1] == "publish_plan.start"
|
|
assert audits.count("publish_plan.step") == 9 and audits[-1] == "publish_plan.done"
|
|
|
|
# 跑第一次:按定时任务触发,应用一打开就有数
|
|
first = await s.wait(steps["first_run"]["result"]["run_id"])
|
|
assert first.status == "succeeded" and first.schedule_id == schedule.id and first.run_class == "formal"
|
|
r = await client.get(f"/api/apps/{application.id}/pages/home")
|
|
assert r.status_code == 200, r.text
|
|
stamp = next(n for n in r.json()["tree"] if n["type"] == "stamp")["data"]["stamp"]
|
|
assert stamp["from"].startswith("定时任务")
|
|
|
|
# 做完了的单不能再执行
|
|
r = await client.post(f"/api/publish-plans/{plan['id']}/run")
|
|
assert r.status_code == 409 and r.json()["code"] == "plan_state"
|
|
|
|
|
|
async def test_preflight_blocks_and_nothing_is_written(client, tmp_path):
|
|
bp = await _board_from_run(client, tmp_path, cadence={**s.WEEKLY, "tz": "Mars/Olympus"}, trial=False)
|
|
workflows, schedules, apps = await _count(Workflow), await _count(Schedule), await _count(App)
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
assert plan["ok"] is False
|
|
codes = {p["code"]: p for p in plan["problems"]}
|
|
assert codes["plan.schedule_invalid"]["level"] == "error" and codes["plan.schedule_invalid"]["plain"]["title"]
|
|
# 没试运行过:照实提醒,不拦
|
|
assert codes["plan.untrialed"]["level"] == "warning"
|
|
|
|
r = await client.post(f"/api/publish-plans/{plan['id']}/run")
|
|
assert r.status_code == 200, r.text
|
|
stopped = r.json()
|
|
steps = _steps(stopped)
|
|
assert stopped["status"] == "stopped" and stopped["ok"] is False
|
|
assert steps["preflight"]["status"] == "failed" and steps["preflight"]["error_code"] == "plan.preflight"
|
|
assert "什么都没写" in steps["preflight"]["message"]
|
|
assert all(st["status"] in ("pending", "skipped") for k, st in steps.items() if k != "preflight")
|
|
# 什么都没写:连工作流版本都没存,没有定时任务、没有应用
|
|
assert (await _count(Workflow), await _count(Schedule), await _count(App)) == (workflows, schedules, apps)
|
|
assert (await s.blueprint(bp["id"])).workflow_id is None
|
|
|
|
|
|
async def test_a_failed_trial_blocks_the_plan(client, tmp_path):
|
|
"""最近一次试运行失败了:挡住发布(用真数跑出了错,发布出去每次出数都是同一个错),写明出错的那一句;不再只是
|
|
「还没有成功的试运行」的提醒。"""
|
|
from sqlalchemy import update
|
|
|
|
bp = await _board_from_run(client, tmp_path)
|
|
async with SessionLocal() as session:
|
|
await session.execute(update(Run).where(Run.id == bp["last_trial_run_id"]).values(
|
|
status="failed", error="工具 db_query__shop 查询失败:(1049, \"Unknown database 'shop'\")\n第二行不要"))
|
|
await session.commit()
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
assert plan["ok"] is False
|
|
codes = {p["code"]: p for p in plan["problems"]}
|
|
failed = codes["plan.trial_failed"]
|
|
assert failed["level"] == "error" and failed["who"] == "you" and failed["plain"]["title"] == "最近一次试运行失败了"
|
|
assert failed["message"] == "最近一次试运行失败了:工具 db_query__shop 查询失败:(1049, \"Unknown database 'shop'\")"
|
|
assert "plan.untrialed" not in codes
|
|
|
|
|
|
async def test_a_rejected_trial_is_a_warning_not_a_failure(client, tmp_path):
|
|
"""最近一次试运行在某一步被驳回(cancelled + end_reason=rejected):那是人的决定,不说成「试运行失败了」、不挡发布,
|
|
照实提醒没走完(05-已知遗留 (a))。"""
|
|
from sqlalchemy import update
|
|
|
|
bp = await _board_from_run(client, tmp_path)
|
|
async with SessionLocal() as session:
|
|
trial = await session.get(Run, bp["last_trial_run_id"])
|
|
node_id = trial.graph["nodes"][-1]["id"]
|
|
label = trial.graph["nodes"][-1]["data"]["label"]
|
|
await session.execute(update(Run).where(Run.id == trial.id).values(
|
|
status="cancelled", end_reason="rejected", error_node_id=node_id, error="人工驳回:不对"))
|
|
await session.commit()
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
codes = {p["code"]: p for p in plan["problems"]}
|
|
assert "plan.trial_failed" not in codes and "plan.untrialed" not in codes
|
|
rejected = codes["plan.trial_rejected"]
|
|
assert rejected["level"] == "warning" and rejected["message"] == f"最近一次试运行在「{label}」被驳回、没有走完"
|
|
assert rejected["plain"]["title"] == "最近一次试运行被驳回、没有走完"
|
|
assert plan["ok"] is True, [p["code"] for p in plan["problems"] if p["level"] == "error"]
|
|
summary = {x["step"]: x["text"] for x in plan["summary"]}
|
|
assert "被驳回、没有走完" in summary["publish_workflow"]
|
|
|
|
|
|
async def test_a_process_with_an_unbound_step_stops_at_preflight(client):
|
|
wf_id = await s.published_workflow(s.expense_graph(), f"报销-{uuid.uuid4().hex[:4]}", description="报销审批")
|
|
bp = (await client.post("/api/blueprints", json={"source": {"kind": "workflow_version", "id": wf_id},
|
|
"scope": "shared"})).json()
|
|
await s.edit_spec(bp["id"], purpose=s.item("报销审批"))
|
|
assert (await client.post(f"/api/blueprints/{bp['id']}/instantiate")).status_code == 200
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
assert plan["ok"] is False
|
|
# 自由形按导入的图列出「谁来做」(05-已知遗留 (d)):会签的 finance、超时升级的 director 各一行,没绑人报在那一行上;
|
|
# 应用检查说的是同一件事(app.slot_unbound),不再重复列——它指的「谁来做」以前在自由形蓝图上不存在
|
|
unbound = {p["path"]: p for p in plan["problems"] if p["code"] == "bp.people_unbound"}
|
|
assert set(unbound) == {"/people/0/who", "/people/1/who"}
|
|
assert unbound["/people/0/who"]["plain"]["who"] == "you" and "谁来做" in unbound["/people/0/who"]["plain"]["action"]
|
|
assert "app.slot_unbound" not in {p["code"] for p in plan["problems"]}
|
|
# 流程类不跑第一次;已发布的那一版按哈希复用,不另存
|
|
assert plan["options"]["first_run"] is False
|
|
summary = {x["step"]: x["text"] for x in plan["summary"]}
|
|
assert "沿用" in summary["save_version"] and "流程类" in summary["first_run"]
|
|
|
|
|
|
@pytest.fixture
|
|
async def erp_write():
|
|
"""一项写入能力:绑定登记端点的 POST,没登记查询(结果未知时平台查不了,要人核实)。"""
|
|
from sqlalchemy import delete
|
|
|
|
from app.capabilities import registry
|
|
from app.db.models import Capability, CustomTool, Endpoint
|
|
|
|
tag = uuid.uuid4().hex[:6]
|
|
name = f"erp_claim_{tag}"
|
|
async with SessionLocal() as session:
|
|
erp = Endpoint(name=f"erp_{tag}", environments={"prod": {"base_url": "https://erp.example.com", "headers": {}}})
|
|
session.add(erp)
|
|
await session.flush()
|
|
session.add(CustomTool(name=name, kind="http", parameters={"type": "object", "properties": {}},
|
|
config={"endpoint": erp.id, "method": "POST", "path": "/claims"}))
|
|
await session.commit()
|
|
await registry.sync_all(session)
|
|
try:
|
|
yield name
|
|
finally:
|
|
async with SessionLocal() as session:
|
|
await session.execute(delete(CustomTool).where(CustomTool.name == name))
|
|
await session.execute(delete(Endpoint).where(Endpoint.name == f"erp_{tag}"))
|
|
await session.execute(delete(Capability).where(Capability.name == name))
|
|
await session.commit()
|
|
await registry.refresh_cache(session)
|
|
|
|
|
|
async def test_a_freeform_blueprint_binds_who_does_each_step_of_the_imported_graph(client, erp_write):
|
|
"""从已发布的「财务审批(岗位 finance)+ 写回(没登记查询)」工作流建自由形蓝图(05-已知遗留 (d)):卡片上按图列出两行
|
|
——finance 和写入结果不明时的核实人;不选人时发布单在这两行上报 bp.people_unbound,不再有指不到卡片的 app.slot_unbound /
|
|
app.reconciler_unbound;选好人之后绑到应用的岗位,发布单不再挡。卡片上只定由谁做,改会签被拒(在画布上改)。"""
|
|
from app.blueprints.publish_plan import bindings_of
|
|
|
|
graph = {"nodes": [
|
|
s.node("start", "input", "发起", fields=[{"name": "amount", "type": "number"}]),
|
|
s.node("review", "human", "财务审批", mode="approve", title="审一下", assignee={"kind": "slot", "slot": "finance"}),
|
|
s.node("pay", "tool", "写回报销单", tool=erp_write, args={}),
|
|
s.node("done", "output", "完成", fields=[{"name": "结论", "value": "已写回"}])],
|
|
"edges": [{"source": "start", "target": "review"},
|
|
{"source": "review", "target": "pay", "sourceHandle": "approved"},
|
|
{"source": "pay", "target": "done"}]}
|
|
wf_id = await s.published_workflow(graph, f"写回-{uuid.uuid4().hex[:4]}", description="报销写回")
|
|
bp = (await client.post("/api/blueprints", json={"source": {"kind": "workflow_version", "id": wf_id},
|
|
"scope": "shared"})).json()
|
|
await s.edit_spec(bp["id"], purpose=s.item("报销审批后写回"))
|
|
assert (await client.post(f"/api/blueprints/{bp['id']}/instantiate")).status_code == 200
|
|
out = (await client.get(f"/api/blueprints/{bp['id']}")).json()
|
|
rows = out["derived_people"]
|
|
assert [(r["kind"], r["slot"], r["path"], r["who"]) for r in rows] == [
|
|
("slot", "finance", "/people/0/who", None), ("reconciler", "reconciler", "/writeback/reconciler", None)]
|
|
assert rows[0]["label"] == "「财务审批」" and rows[0]["detail"] == "「财务审批」处理"
|
|
assert rows[1]["detail"] == "「写回报销单」写入结果不明时去核实"
|
|
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
assert plan["ok"] is False
|
|
codes = [p["code"] for p in plan["problems"]]
|
|
assert sorted(p["path"] for p in plan["problems"] if p["code"] == "bp.people_unbound") == [
|
|
"/people/0/who", "/writeback/reconciler"]
|
|
assert "app.slot_unbound" not in codes and "app.reconciler_unbound" not in codes
|
|
summary_you = [p for p in plan["problems"] if p["code"] == "bp.people_unbound"]
|
|
assert all(p["who"] == "you" for p in summary_you)
|
|
|
|
refused = await client.patch(f"/api/blueprints/{bp['id']}", json={"ops": [
|
|
{"op": "bp_set", "path": "/people/0/policy", "value": {"kind": "all"}}]})
|
|
assert refused.status_code == 422 and refused.json()["code"] == "bp.template_mismatch"
|
|
assert "在画布上改" in refused.json()["detail"]
|
|
r = await client.patch(f"/api/blueprints/{bp['id']}", json={"ops": [
|
|
{"op": "bp_set", "path": "/people/0/who", "value": [{"kind": "user", "name": "zhang"}]},
|
|
{"op": "bp_set", "path": "/writeback/reconciler", "value": [{"kind": "group", "name": "财务组"}]}]})
|
|
assert r.status_code == 200, r.text
|
|
rows = r.json()["derived_people"]
|
|
assert rows[0]["who"] == [{"kind": "user", "name": "zhang"}] and rows[1]["who"] == [{"kind": "group", "name": "财务组"}]
|
|
assert not [i for i in r.json()["issues"] if i["code"] == "bp.people_unbound"]
|
|
assert bindings_of(await s.blueprint(bp["id"]))["slots"] == {
|
|
"finance": [{"kind": "user", "name": "zhang"}], "reconciler": [{"kind": "group", "name": "财务组"}]}
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
codes = [p["code"] for p in plan["problems"]]
|
|
assert "bp.people_unbound" not in codes and "app.slot_unbound" not in codes and "app.reconciler_unbound" not in codes
|
|
|
|
|
|
async def test_the_reconciler_comes_from_the_writeback_card(client, erp_write):
|
|
"""审批通过后写回一项没登记查询的能力:结果不明时要人核实。发布单只列一条、指到「写回哪里」卡片上那一项(应用检查说的
|
|
核实岗位没绑、写入没人核实是同一件事,不再重复列);在卡片上选了人,绑到应用的核实岗位,这几条都不再有。"""
|
|
from app.blueprints.templates import get_template
|
|
|
|
bp = (await client.post("/api/blueprints", json={"source": {"kind": "template", "id": "approval"},
|
|
"scope": "shared"})).json()
|
|
example = get_template("approval").example()
|
|
await s.edit_spec(bp["id"], **{k: example[k] for k in ("purpose", "people", "fields", "access")},
|
|
writeback={"capability": s.item(erp_write), "mapping": s.item({"amount": "form.amount"}),
|
|
"confirm": s.item(True), "reconciler": s.item(None, status="missing")})
|
|
assert (await client.get(f"/api/blueprints/{bp['id']}")).json()["reconciler_needed"] is True
|
|
|
|
def about_reconciler(plan: dict) -> list[str]:
|
|
return sorted(p["code"] for p in plan["problems"] if p["code"] in ("bp.reconciler_unbound", "app.reconciler_unbound")
|
|
or (p["code"] == "app.slot_unbound" and p.get("path") == "slots.reconciler"))
|
|
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
assert plan["ok"] is False and about_reconciler(plan) == ["bp.reconciler_unbound"]
|
|
(problem,) = [p for p in plan["problems"] if p["code"] == "bp.reconciler_unbound"]
|
|
assert problem["who"] == "you" and problem["path"] == "/writeback/reconciler"
|
|
assert "结果不明时谁去核实" in problem["plain"]["action"]
|
|
|
|
r = await client.patch(f"/api/blueprints/{bp['id']}", json={"ops": [
|
|
{"op": "bp_set", "path": "/writeback/reconciler", "value": [{"kind": "group", "name": "财务组"}]}]})
|
|
assert r.status_code == 200, r.text
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
assert about_reconciler(plan) == [], [p["code"] for p in plan["problems"]]
|
|
|
|
|
|
async def test_a_failed_step_stops_and_resume_does_not_redo_what_is_done(client, tmp_path, monkeypatch):
|
|
from app.blueprints import publish_plan as plans
|
|
|
|
bp = await _board_from_run(client, tmp_path)
|
|
original = plans._STEPS["publish_app"]
|
|
calls = {"n": 0}
|
|
|
|
async def flaky(session, ctx):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
raise plans.StepFailed(plans.APP_PUBLISH, "工作流已经发布,应用没有通过检查、还没有发布;使用者暂时看不到它")
|
|
return await original(session, ctx)
|
|
|
|
monkeypatch.setitem(plans._STEPS, "publish_app", flaky)
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
stopped = (await client.post(f"/api/publish-plans/{plan['id']}/run")).json()
|
|
steps = _steps(stopped)
|
|
assert stopped["status"] == "stopped"
|
|
assert [k for k, st in steps.items() if st["status"] == "done"] == [
|
|
"save_version", "preflight", "publish_workflow", "create_schedule", "create_app"]
|
|
assert steps["publish_app"]["status"] == "failed" and steps["publish_app"]["error_code"] == "plan.app_publish"
|
|
assert steps["publish_app"]["message"].startswith("工作流已经发布")
|
|
# 中途停下的状态无害:定时任务停用着、应用停在草稿、没授权
|
|
async with SessionLocal() as session:
|
|
schedule = await session.get(Schedule, steps["create_schedule"]["result"]["schedule_id"])
|
|
application = await session.get(App, steps["create_app"]["result"]["app_id"])
|
|
grants = (await session.execute(select(AppGrant).where(AppGrant.app_id == application.id))).scalars().all()
|
|
assert schedule.enabled is False and schedule.next_run_at is None
|
|
assert application.published_version is None and not grants
|
|
# 停下的单不能「执行」,要「接着做」
|
|
r = await client.post(f"/api/publish-plans/{plan['id']}/run")
|
|
assert r.status_code == 409 and r.json()["code"] == "plan_state"
|
|
|
|
versions, schedules, apps = await _count(WorkflowVersion), await _count(Schedule), await _count(App)
|
|
r = await client.post(f"/api/publish-plans/{plan['id']}/resume")
|
|
assert r.status_code == 200, r.text
|
|
done = r.json()
|
|
assert done["status"] == "done", done["steps"]
|
|
again = _steps(done)
|
|
# 已完成的按 id 核对、不重做:同一个工作流版本、同一个定时任务、同一个应用
|
|
for kind in ("save_version", "create_schedule", "create_app"):
|
|
assert again[kind]["result"] == steps[kind]["result"]
|
|
assert (await _count(WorkflowVersion), await _count(Schedule), await _count(App)) == (versions, schedules, apps)
|
|
async with SessionLocal() as session:
|
|
schedule = await session.get(Schedule, steps["create_schedule"]["result"]["schedule_id"])
|
|
application = await session.get(App, steps["create_app"]["result"]["app_id"])
|
|
assert schedule.enabled is True and application.published_version == 1
|
|
await s.wait(again["first_run"]["result"]["run_id"])
|
|
|
|
|
|
async def test_resume_stops_when_something_done_is_gone(client, tmp_path, monkeypatch):
|
|
from app.blueprints import publish_plan as plans
|
|
|
|
bp = await _board_from_run(client, tmp_path)
|
|
|
|
async def broken(session, ctx):
|
|
raise plans.StepFailed(plans.APP_PUBLISH, "工作流已经发布,应用还没有")
|
|
|
|
monkeypatch.setitem(plans._STEPS, "publish_app", broken)
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
stopped = (await client.post(f"/api/publish-plans/{plan['id']}/run")).json()
|
|
schedule_id = _steps(stopped)["create_schedule"]["result"]["schedule_id"]
|
|
async with SessionLocal() as session:
|
|
await session.delete(await session.get(Schedule, schedule_id))
|
|
await session.commit()
|
|
schedules = await _count(Schedule)
|
|
again = (await client.post(f"/api/publish-plans/{plan['id']}/resume")).json()
|
|
steps = _steps(again)
|
|
assert again["status"] == "stopped"
|
|
assert steps["create_schedule"]["status"] == "failed"
|
|
assert steps["create_schedule"]["error_code"] == "plan.object_missing"
|
|
# 不另建一份;这份单不能再接着做,要重新出单
|
|
assert await _count(Schedule) == schedules
|
|
r = await client.post(f"/api/publish-plans/{plan['id']}/resume")
|
|
assert r.status_code == 409 and r.json()["code"] == "plan.object_missing"
|
|
|
|
|
|
async def test_a_changed_graph_refuses_to_run(client, tmp_path):
|
|
bp = await _board_from_run(client, tmp_path)
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
# 出单之后改了图(卡片改了、重新实例化)
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Blueprint, bp["id"])
|
|
graph = {**row.graph, "nodes": [{**n, "data": {**n["data"], "label": "改过的"}} if n["id"] == "done" else n
|
|
for n in row.graph["nodes"]]}
|
|
row.graph = graph
|
|
await session.commit()
|
|
assert (await client.post(f"/api/blueprints/{bp['id']}/instantiate")).status_code == 200
|
|
r = await client.post(f"/api/publish-plans/{plan['id']}/run")
|
|
assert r.status_code == 409 and r.json()["code"] == "plan.graph_changed"
|
|
assert (await s.blueprint(bp["id"])).workflow_id is None
|
|
|
|
# 重新出单:发布的图和最近一次试运行的不同,照实提醒(不拦)
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
differs = [p for p in plan["problems"] if p["code"] == "plan.trial_differs"]
|
|
assert differs and differs[0]["level"] == "warning" and plan["ok"] is True
|
|
|
|
|
|
@pytest.fixture
|
|
async def login():
|
|
await auth_env.reset()
|
|
try:
|
|
yield
|
|
finally:
|
|
await auth_env.reset()
|
|
|
|
|
|
async def test_governed_plans_need_an_admin(login, tmp_path):
|
|
admin = await auth_env.turn_on_local()
|
|
u = uuid.uuid4().hex[:6]
|
|
await auth_env.make_user(f"zhao{u}", "builder")
|
|
builder = await auth_env.login(f"zhao{u}")
|
|
source = await s.register_source(s.make_source(tmp_path))
|
|
wf_id = await s.published_workflow(s.board_graph(source), f"周报-{u}", description="每周看上周销售额")
|
|
bp = (await builder.post("/api/blueprints", json={"source": {"kind": "workflow_version", "id": wf_id},
|
|
"scope": "shared"})).json()
|
|
await s.edit_spec(bp["id"], purpose=s.item("每周看上周销售额"))
|
|
assert (await builder.post(f"/api/blueprints/{bp['id']}/instantiate")).status_code == 200
|
|
|
|
plan = (await builder.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "governed"})).json()
|
|
admin_only = [p for p in plan["problems"] if p["code"] == "plan.admin_required"]
|
|
assert plan["ok"] is False and admin_only and admin_only[0]["plain"]["who"] == "admin"
|
|
r = await builder.post(f"/api/publish-plans/{plan['id']}/run")
|
|
assert r.status_code == 403 and r.json()["code"] == "admin_only"
|
|
# 管理员出的受管级单子不报这一条
|
|
plan = (await admin.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "governed"})).json()
|
|
assert not [p for p in plan["problems"] if p["code"] == "plan.admin_required"]
|
|
# 使用者看不到发布单
|
|
await auth_env.make_user(f"lee{u}", "user")
|
|
user = await auth_env.login(f"lee{u}")
|
|
# 使用者碰共享蓝图的发布单回 404,和看不到的一样(D5)
|
|
assert (await user.get(f"/api/publish-plans/{plan['id']}")).status_code == 404
|
|
|
|
|
|
async def test_runs_launched_by_a_plan_are_formal_and_from_the_published_version(client, tmp_path):
|
|
"""看板没有定时任务时,跑第一次从发布的那一版发起正式运行(来源 launcher)。"""
|
|
bp = await _board_from_run(client, tmp_path)
|
|
await s.edit_spec(bp["id"], cadence=s.item(None, status="missing"))
|
|
assert (await client.post(f"/api/blueprints/{bp['id']}/instantiate")).status_code == 200
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
steps = _steps(plan)
|
|
assert steps["create_schedule"]["status"] == "skipped" and steps["enable_schedule"]["status"] == "skipped"
|
|
done = (await client.post(f"/api/publish-plans/{plan['id']}/run")).json()
|
|
assert done["status"] == "done", done["steps"]
|
|
run = await s.wait(_steps(done)["first_run"]["result"]["run_id"])
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Run, run.id)
|
|
assert row.run_class == "formal" and row.version == 1 and row.origin_kind == "launcher"
|
|
|
|
|
|
async def test_a_published_review_copy_closes_the_review(client, tmp_path, monkeypatch):
|
|
"""审核副本(个人应用提交审核,搭建者在副本上走发布单)做完之后,原蓝图的审核待办记为已发布(blueprints/personal.py)。"""
|
|
from app.blueprints import personal
|
|
|
|
bp = await _board_from_run(client, tmp_path)
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Blueprint, bp["id"])
|
|
row.copied_from = "original-bp"
|
|
await session.commit()
|
|
seen = []
|
|
|
|
async def published(session, blueprint, *, app_id, principal):
|
|
seen.append((blueprint.id, app_id, principal.username))
|
|
return "task-1"
|
|
|
|
monkeypatch.setattr(personal, "review_published", published)
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published",
|
|
"first_run": False})).json()
|
|
done = (await client.post(f"/api/publish-plans/{plan['id']}/run")).json()
|
|
assert done["status"] == "done" and _steps(done)["first_run"]["status"] == "skipped"
|
|
assert seen == [(bp["id"], _steps(done)["create_app"]["result"]["app_id"], "builder1")]
|
|
|
|
|
|
async def test_publishing_again_upgrades_the_same_app_and_keeps_its_schedule(client, tmp_path):
|
|
"""已经发布过的蓝图改了取数再发一版(N6 改取数回到蓝图):同一个工作流存新版本、同一个应用升版(口径变了要处置)、
|
|
沿用原来的定时任务(按蓝图更新时间),不另建一份。"""
|
|
bp = await _board_from_run(client, tmp_path)
|
|
first = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published",
|
|
"first_run": False})).json()
|
|
first = (await client.post(f"/api/publish-plans/{first['id']}/run")).json()
|
|
assert first["status"] == "done", first["steps"]
|
|
schedule_id = _steps(first)["create_schedule"]["result"]["schedule_id"]
|
|
app_id = _steps(first)["create_app"]["result"]["app_id"]
|
|
|
|
# 改取数:销售额去掉 999 的那一单;改「多久更新」:每周二
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Blueprint, bp["id"])
|
|
nodes = []
|
|
for n in row.graph["nodes"]:
|
|
if n["id"] == "q":
|
|
config = {**n["data"]["config"], "args": {"sql": s.GMV_SQL + " AND amount < 900"}}
|
|
n = {**n, "data": {**n["data"], "config": config}}
|
|
nodes.append(n)
|
|
row.graph = {**row.graph, "nodes": nodes}
|
|
row.spec = {**row.spec, "cadence": s.item({**s.WEEKLY, "weekday": 2})}
|
|
await session.commit()
|
|
r = await client.post(f"/api/blueprints/{bp['id']}/trial", json={"inputs": s.PERIOD})
|
|
assert (await s.wait(r.json()["run_id"])).status == "succeeded"
|
|
|
|
# 口径变了,没选处置:停在预检,你来定
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published",
|
|
"first_run": False})).json()
|
|
upgrade = [p for p in plan["problems"] if p["code"] == "app.upgrade_policy"]
|
|
assert plan["ok"] is False and upgrade and upgrade[0]["plain"]["who"] == "you"
|
|
summary = {x["step"]: x["text"] for x in plan["summary"]}
|
|
assert "沿用应用正在用的定时任务" in summary["create_schedule"] and "每周二" in summary["create_schedule"]
|
|
assert summary["create_app"].startswith("更新应用")
|
|
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={
|
|
"level": "published", "first_run": False, "dispositions": {"main": "recompute"}})).json()
|
|
assert plan["ok"] is True, plan["problems"]
|
|
done = (await client.post(f"/api/publish-plans/{plan['id']}/run")).json()
|
|
assert done["status"] == "done", done["steps"]
|
|
steps = _steps(done)
|
|
assert steps["create_schedule"]["result"] == {"schedule_id": schedule_id, "enabled": True, "reused": True}
|
|
assert steps["create_app"]["result"] == {"app_id": app_id, "created": False}
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Blueprint, bp["id"])
|
|
wf = await session.get(Workflow, row.workflow_id)
|
|
schedule = await session.get(Schedule, schedule_id)
|
|
count = int(await session.scalar(select(func.count(Schedule.id)).where(Schedule.workflow_id == wf.id)) or 0)
|
|
version = (await session.execute(select(AppVersion).where(
|
|
AppVersion.app_id == app_id, AppVersion.version == 2))).scalar_one()
|
|
assert wf.published_version == 2 and count == 1
|
|
assert schedule.cron.endswith(" 2") and schedule.enabled is True
|
|
assert version.pins[0]["version"] == 2 and version.dispositions == {"main": "recompute"}
|
|
assert version.spec["views"]["latest"]["source"]["from"] == f"schedule:{schedule_id}"
|
|
|
|
|
|
async def test_problems_are_split_into_three_kinds(client):
|
|
"""预检的问题带白话和归谁处理(摘要按它分三栏):岗位没绑人是「你来定」。"""
|
|
wf_id = await s.published_workflow(s.expense_graph(), f"报销-{uuid.uuid4().hex[:4]}", description="报销审批")
|
|
bp = (await client.post("/api/blueprints", json={"source": {"kind": "workflow_version", "id": wf_id},
|
|
"scope": "shared"})).json()
|
|
await s.edit_spec(bp["id"], purpose=s.item("报销审批"))
|
|
assert (await client.post(f"/api/blueprints/{bp['id']}/instantiate")).status_code == 200
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
|
assert all(p["who"] in ("you", "assistant", "admin") for p in plan["problems"])
|
|
assert {p["source"] for p in plan["problems"]} <= {"blueprint", "plan", "workflow", "schedule", "app", "grants"}
|
|
unbound = next(p for p in plan["problems"] if p["code"] == "bp.people_unbound")
|
|
assert unbound["who"] == "you" and unbound["source"] == "blueprint"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 复核修复:各包接缝上的几件事
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def _approval_blueprint(client) -> str:
|
|
"""审批流程模板的蓝图:只有一步主管审批(处理人写了具体的人,岗位按卡片绑上),没有写回。"""
|
|
from app.blueprints.templates import get_template
|
|
|
|
bp = (await client.post("/api/blueprints", json={"source": {"kind": "template", "id": "approval"}, "scope": "shared",
|
|
"name": f"报销-{uuid.uuid4().hex[:4]}"})).json()
|
|
spec = get_template("approval").example()
|
|
spec["people"] = spec["people"][:1]
|
|
spec["access"] = {"use": [{"kind": "user", "name": "zhang"}], "observe": [], "manage": []}
|
|
await s.edit_spec(bp["id"], **spec)
|
|
return bp["id"]
|
|
|
|
|
|
def _human(graph: dict) -> dict:
|
|
return next(n for n in graph["nodes"] if n["type"] == "human")["data"]["config"]
|
|
|
|
|
|
async def test_cards_changed_after_the_trial_are_what_gets_published(client):
|
|
"""试运行(实例化)之后改了「谁来做」的时限:图变了,出单时按卡片重新实例化,发布的是新图;和最近一次试运行不同照实
|
|
提醒。出单之后再改卡片(这次图哈希不一定变),执行时按卡片内容的指纹认出来,拒绝执行。"""
|
|
bp_id = await _approval_blueprint(client)
|
|
assert (await client.post(f"/api/blueprints/{bp_id}/instantiate")).status_code == 200
|
|
before = await s.blueprint(bp_id)
|
|
assert _human(before.graph)["due"] == "P2D"
|
|
|
|
r = await client.patch(f"/api/blueprints/{bp_id}", json={"ops": [
|
|
{"op": "bp_set", "path": "/people/0/due", "value": "P1D"}]})
|
|
assert r.status_code == 200, r.text
|
|
assert (await s.blueprint(bp_id)).graph_hash == before.graph_hash, "卡片上的修改只刷新关节,不重算图"
|
|
|
|
plan = (await client.post(f"/api/blueprints/{bp_id}/publish-plan", json={"level": "published"})).json()
|
|
assert plan["ok"] is True, plan["problems"]
|
|
now = await s.blueprint(bp_id)
|
|
assert plan["graph_hash"] == now.graph_hash != before.graph_hash and _human(now.graph)["due"] == "P1D"
|
|
assert plan["options"]["spec_hash"]
|
|
|
|
# 出单之后又改了卡片(换处理人只改岗位绑定、不改图):执行前按卡片内容的指纹认出来
|
|
r = await client.patch(f"/api/blueprints/{bp_id}", json={"ops": [
|
|
{"op": "bp_set", "path": "/people/0/who", "value": [{"kind": "user", "name": "li"}]}]})
|
|
assert r.status_code == 200, r.text
|
|
assert (await s.blueprint(bp_id)).graph_hash == now.graph_hash
|
|
r = await client.post(f"/api/publish-plans/{plan['id']}/run")
|
|
assert r.status_code == 409 and r.json()["code"] == "plan.graph_changed"
|
|
|
|
plan = (await client.post(f"/api/blueprints/{bp_id}/publish-plan", json={"level": "published"})).json()
|
|
done = (await client.post(f"/api/publish-plans/{plan['id']}/run")).json()
|
|
assert done["status"] == "done", done["steps"]
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Blueprint, bp_id)
|
|
version = (await session.execute(select(WorkflowVersion).where(
|
|
WorkflowVersion.workflow_id == row.workflow_id))).scalars().one()
|
|
application = await session.get(App, row.app_id)
|
|
published = (await session.execute(select(AppVersion).where(AppVersion.app_id == application.id))).scalars().one()
|
|
assert _human(version.graph)["due"] == "P1D", "发布的是卡片上写的时限"
|
|
assert published.bindings["slots"]["lead"] == [{"kind": "user", "name": "li"}], "发布的是卡片上写的处理人"
|
|
|
|
|
|
async def test_a_plan_cannot_take_over_an_app_the_builder_cannot_manage(login, tmp_path):
|
|
"""蓝图已经发布成应用:只有对那个应用有管理权限的人能出单、执行(发布单会改它的草稿、发布它、整份替换授权)。
|
|
|
|
- 别的搭建者连这张共享蓝图都看不到(D5):读、改、出单、看单子一律 404,列表里没有;给了那个应用的管理授权之后才
|
|
看得到;
|
|
- 蓝图的拥有者对应用没有管理权限(应用是管理员替他发布的、只授给他使用)时,把自己写进「谁能看」的管理想借发布单
|
|
拿到管理权:出单时预检报 plan.app_forbidden(要找管理员),执行 403;应用的授权没被改。"""
|
|
import json
|
|
|
|
root = await auth_env.turn_on_local()
|
|
u = uuid.uuid4().hex[:6]
|
|
await auth_env.make_user(f"zhao{u}", "builder")
|
|
await auth_env.make_user(f"qian{u}", "builder")
|
|
owner = await auth_env.login(f"zhao{u}")
|
|
other = await auth_env.login(f"qian{u}")
|
|
source = await s.register_source(s.make_source(tmp_path))
|
|
wf_id = await s.published_workflow(s.board_graph(source), f"周报-{u}", description="每周看上周销售额")
|
|
bp = (await owner.post("/api/blueprints", json={"source": {"kind": "workflow_version", "id": wf_id},
|
|
"scope": "shared"})).json()
|
|
await s.edit_spec(bp["id"], purpose=s.item("每周看上周销售额"),
|
|
access={"use": [{"kind": "user", "name": f"zhao{u}"}], "observe": [], "manage": []})
|
|
# 管理员能读写、出单(D5):应用由管理员发布,创建人是管理员
|
|
plan = (await root.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published",
|
|
"first_run": False})).json()
|
|
assert not [p for p in plan["problems"] if p["code"] == "plan.app_forbidden"]
|
|
done = (await root.post(f"/api/publish-plans/{plan['id']}/run")).json()
|
|
assert done["status"] == "done", done["steps"]
|
|
app_id = (await s.blueprint(bp["id"])).app_id
|
|
|
|
# 别的搭建者:当这张蓝图不存在
|
|
assert (await other.get(f"/api/blueprints/{bp['id']}")).status_code == 404
|
|
assert (await other.patch(f"/api/blueprints/{bp['id']}", json={"name": "改个名"})).status_code == 404
|
|
assert (await other.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).status_code \
|
|
== 404
|
|
assert (await other.get(f"/api/publish-plans/{plan['id']}")).status_code == 404
|
|
assert (await other.post(f"/api/publish-plans/{plan['id']}/run")).status_code == 404
|
|
assert bp["id"] not in [b["id"] for b in (await other.get("/api/blueprints")).json()["items"]]
|
|
assert bp["id"] in [b["id"] for b in (await owner.get("/api/blueprints")).json()["items"]]
|
|
up = await other.post("/api/files", files={"file": ("说明.txt", "x".encode(), "text/plain")},
|
|
data={"context": json.dumps({"blueprint_id": bp["id"]})})
|
|
assert up.status_code == 404 and up.json()["code"] == "files.context_invalid", "往别人的蓝图上传附件同样不收"
|
|
ghost = await other.post("/api/files", files={"file": ("说明.txt", "x".encode(), "text/plain")},
|
|
data={"context": json.dumps({"blueprint_id": uuid.uuid4().hex})})
|
|
assert (ghost.status_code, ghost.json()) == (up.status_code, up.json()), "看不到的和不存在的一个说法,试不出它在不在"
|
|
assert bp["id"] in [b["id"] for b in (await root.get("/api/blueprints")).json()["items"]]
|
|
|
|
# 拥有者把自己写进「谁能看」的管理,想借发布单拿到管理权
|
|
await s.edit_spec(bp["id"], access={"use": [], "observe": [], "manage": [{"kind": "user", "name": f"zhao{u}"}]})
|
|
plan = (await owner.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published",
|
|
"first_run": False})).json()
|
|
forbidden = [p for p in plan["problems"] if p["code"] == "plan.app_forbidden"]
|
|
assert plan["ok"] is False and forbidden and forbidden[0]["who"] == "admin" and forbidden[0]["plain"]["title"]
|
|
r = await owner.post(f"/api/publish-plans/{plan['id']}/run")
|
|
assert r.status_code == 403 and r.json()["code"] == "plan.app_forbidden", r.text
|
|
async with SessionLocal() as session:
|
|
grants = {(g.principal, g.role) for g in (await session.execute(
|
|
select(AppGrant).where(AppGrant.app_id == app_id))).scalars()}
|
|
assert grants == {(f"zhao{u}", "use")}, "授权没被改"
|
|
|
|
# 应用的管理者看得到它出自的蓝图(D5)
|
|
async with SessionLocal() as session:
|
|
session.add(AppGrant(app_id=app_id, principal_kind="user", principal=f"qian{u}", role="manage"))
|
|
await session.commit()
|
|
assert (await other.get(f"/api/blueprints/{bp['id']}")).status_code == 200
|
|
assert bp["id"] in [b["id"] for b in (await other.get("/api/blueprints")).json()["items"]]
|
|
|
|
|
|
async def test_plans_left_running_by_a_dead_process_are_stopped_at_startup(client, tmp_path):
|
|
"""执行到一半进程没了:单子停在「正在执行」,谁也接不着。启动时改成停下,停在没做完的那一步、写明执行中断,就能接着做。"""
|
|
from app.blueprints import publish_plan as plans
|
|
from app.db.models_blueprints import PublishPlan
|
|
|
|
bp = await _board_from_run(client, tmp_path)
|
|
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published",
|
|
"first_run": False})).json()
|
|
async with SessionLocal() as session:
|
|
row = await session.get(PublishPlan, plan["id"])
|
|
steps = [dict(st) for st in row.steps]
|
|
steps[0].update(status="done", result={"workflow_id": "x"})
|
|
row.steps, row.status = steps, "running"
|
|
await session.commit()
|
|
assert await plans.startup() >= 1
|
|
got = (await client.get(f"/api/publish-plans/{plan['id']}")).json()
|
|
stopped = _steps(got)
|
|
assert got["status"] == "stopped" and stopped["save_version"]["status"] == "done"
|
|
assert stopped["preflight"]["status"] == "failed" and stopped["preflight"]["error_code"] == "plan.interrupted"
|
|
assert "重启" in stopped["preflight"]["message"]
|
|
from app.plain import plain_of
|
|
|
|
assert plain_of("plan.interrupted") is not None
|
|
|
|
|
|
# ---------------------------------------------------------------- 填报登记:试运行 → 发布单 → 入口提交
|
|
|
|
|
|
RECORD_FIELDS = [
|
|
{"name": "store", "spec": s.item({"type": "string", "title": "门店", "required": True}), "sensitive": False},
|
|
{"name": "amount", "spec": s.item({"type": "number", "title": "金额", "required": True}), "sensitive": False},
|
|
{"name": "reason", "spec": s.item({"type": "string", "title": "原因"}), "sensitive": True},
|
|
]
|
|
|
|
|
|
async def _records_blueprint(client) -> str:
|
|
bp = (await client.post("/api/blueprints", json={"source": {"kind": "template", "id": "records"}, "scope": "shared",
|
|
"name": f"报损-{uuid.uuid4().hex[:4]}"})).json()
|
|
await s.edit_spec(bp["id"], purpose=s.item("门店报损登记"), fields=RECORD_FIELDS, people=[], data=[],
|
|
cadence=s.item(s.WEEKLY),
|
|
access={"use": [{"kind": "user", "name": "amy"}, {"kind": "user", "name": "bob"}],
|
|
"observe": [], "manage": []})
|
|
return bp["id"]
|
|
|
|
|
|
def _append_table(graph: dict) -> str:
|
|
return next(n for n in graph["nodes"] if n["type"] == "tool")["data"]["config"]["args"]["table"]
|
|
|
|
|
|
async def _publish(client, bp_id: str) -> dict:
|
|
plan = (await client.post(f"/api/blueprints/{bp_id}/publish-plan", json={"level": "published"})).json()
|
|
if any(p["code"] == "app.upgrade_policy" for p in plan["problems"]):
|
|
plan = (await client.post(f"/api/blueprints/{bp_id}/publish-plan", json={
|
|
"level": "published", "dispositions": {"main": "incomparable"}})).json()
|
|
assert plan["ok"] is True, plan["problems"]
|
|
done = (await client.post(f"/api/publish-plans/{plan['id']}/run")).json()
|
|
assert done["status"] == "done", done["steps"]
|
|
return done
|
|
|
|
|
|
async def _submit(client, app_id: str, values: dict, who: str) -> Run:
|
|
r = await client.post(f"/api/apps/{app_id}/entries/start", json={"values": values}, headers={"X-Actor": who})
|
|
assert r.status_code == 201, r.text
|
|
run = await s.wait(r.json()["run_id"])
|
|
assert run.status == "succeeded", run.error
|
|
return run
|
|
|
|
|
|
async def test_records_blueprint_trials_publishes_and_submissions_land_in_the_table(client):
|
|
"""填报登记走一遍:试运行(示例表单走填报那条路,写入演练)→ 发布单 → 入口提交,记录表里有那一行。图里写的记录表是
|
|
「键.v版本」(和填关节、卡片修改同一种写法),正式运行写得进去。
|
|
|
|
之后在蓝图上不兼容地改表单(金额改成文字)再试运行:起草时出第 2 版,线上应用固定在第 1 版、照常收提交;发布之后才
|
|
切到第 2 版、第 1 版停用。记录列表里别人提交的行,敏感列写「已遮罩」。"""
|
|
from app.db.models_blueprints import RecordsTable
|
|
from app.records import store
|
|
from app.records.tables import path_of
|
|
|
|
from app.capabilities.registry import sync_all
|
|
|
|
async with SessionLocal() as session:
|
|
# 平台能力 records_append 登记进能力表(启动时同步的那一次):带查询能力,应用不用绑核实岗位
|
|
await sync_all(session)
|
|
bp_id = await _records_blueprint(client)
|
|
r = await client.post(f"/api/blueprints/{bp_id}/trial", json={"inputs": {"store": "一店", "amount": 3}})
|
|
assert r.status_code == 200, r.text
|
|
trial = await s.wait(r.json()["run_id"])
|
|
assert trial.status == "succeeded", trial.error
|
|
assert trial.input["mode"] == "submit" and trial.input["store"] == "一店", "填了示例表单:试的是填报那条路"
|
|
row = await s.blueprint(bp_id)
|
|
assert _append_table(row.graph) == f"{bp_id}.v1"
|
|
|
|
await _publish(client, bp_id)
|
|
app_id = (await s.blueprint(bp_id)).app_id
|
|
await _submit(client, app_id, {"store": "一店", "amount": 12.5, "reason": "过期"}, "amy")
|
|
await _submit(client, app_id, {"store": "二店", "amount": 3, "reason": "破损"}, "bob")
|
|
async with SessionLocal() as session:
|
|
v1 = (await session.execute(select(RecordsTable).where(RecordsTable.table_key == bp_id))).scalars().one()
|
|
assert v1.app_id == app_id and v1.retired_at is None
|
|
assert store.high_water(path_of(v1)) == 2, "试运行一行都没写,两次提交各一行"
|
|
|
|
# 记录列表:别人提交的行,敏感列写「已遮罩」;自己的照原样
|
|
page = (await client.get(f"/api/apps/{app_id}/records", headers={"X-Actor": "amy"})).json()
|
|
by = {r["_submitted_by"]: r["values"] for r in page["items"]}
|
|
assert by["amy"]["reason"] == "过期" and by["bob"]["reason"] == "已遮罩" and by["bob"]["store"] == "二店"
|
|
|
|
# 草稿上不兼容地改表单、试运行:出第 2 版,线上那一版不停用
|
|
fields = [RECORD_FIELDS[0], {**RECORD_FIELDS[1], "spec": s.item({"type": "string", "title": "金额"})}, RECORD_FIELDS[2]]
|
|
await s.edit_spec(bp_id, fields=fields)
|
|
r = await client.post(f"/api/blueprints/{bp_id}/trial", json={"inputs": {"store": "三店", "amount": "三元"}})
|
|
assert (await s.wait(r.json()["run_id"])).status == "succeeded"
|
|
assert _append_table((await s.blueprint(bp_id)).graph) == f"{bp_id}.v2"
|
|
async with SessionLocal() as session:
|
|
versions = list((await session.execute(select(RecordsTable).where(RecordsTable.table_key == bp_id)
|
|
.order_by(RecordsTable.version))).scalars())
|
|
assert [(v.version, v.retired_at is None) for v in versions] == [(1, True), (2, True)]
|
|
await _submit(client, app_id, {"store": "一店", "amount": 7, "reason": "过期"}, "amy")
|
|
assert store.high_water(path_of(versions[0])) == 3, "线上应用照常写第 1 版"
|
|
|
|
# 发布之后才切换:第 1 版停用(写明换成了第 2 版),新的提交写进第 2 版
|
|
done = await _publish(client, bp_id)
|
|
assert {st["kind"]: st for st in done["steps"]}["publish_app"]["result"]["records"] == [f"{bp_id}.v2"]
|
|
async with SessionLocal() as session:
|
|
versions = list((await session.execute(select(RecordsTable).where(RecordsTable.table_key == bp_id)
|
|
.order_by(RecordsTable.version))).scalars())
|
|
assert versions[0].retired_at is not None and "第 2 版" in versions[0].note and versions[1].retired_at is None
|
|
await _submit(client, app_id, {"store": "四店", "amount": "五元"}, "amy")
|
|
assert store.high_water(path_of(versions[1])) == 1 and store.high_water(path_of(versions[0])) == 3
|