Files
AgentLab/backend/tests/test_schedules_api.py
T
ThearchyHelios f0f1b99ead 定时运行结束后自动和同一定时任务的上一次对比:结果存成工件记在触发记录上,触发历史和通知只给计数摘要与对比页链接
- 新增 app/scheduler/auto_compare.py:通知扫描认领到运行进入终态(结束、失败、取消)的那一次之后、推送之前,
  调运行对比接口的现成函数(default_base、compare_runs,不改它们)比一次。基准只取 default_base 的「同一定时
  任务」那一档,退回「同一工作流」的不比——那不是这个任务的上一次。没封存的不比;比不了(基准封存核对不一致等)
  记警告、意外异常记堆栈,都不影响通知。整份结果存成 run_compare 工件,不挂在运行名下(不进运行的工件列表),
  工件 id 写进 ScheduleFire.compare_artifact;再次进入终态(失败后继续执行)时按新的封存重新比、覆盖旧结果
- 接口:Fire 多一个 compare: {base_run_id, summary}(summary 同对比接口的计数)或 null。运行现在不在终态
  (被继续执行)、工件读不出来时为 null;工件摘要按 id 缓存(内容寻址,不会过期),读盘在线程里
- 通知:结束、失败的通知多一行「与上一次相比:只数据变 3 个、口径与数据都变 1 个、SQL 结构变 0 个」(三类总写,
  其余各类有才写,两边都没有指标时不写一串 0 个),配了对外访问地址时附对比页链接(/runs/compare?target=…&base=…),
  四种格式都带;通用格式多一个 compare 字段。只给个数,不放任何具体数字
- 没配 Webhook 的任务也比:触发历史里要用
2026-10-05 18:45:59 +08:00

674 lines
36 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.
"""定时任务接口(api/schedules.py),按前后端约定的形状:
- Schedule:{id, name, workflow_id, workflow_name, cron, timezone, run_class, input_template, enabled, overlap,
misfire_grace_s, webhook: {format, url_hint, has_secret, events} | null, next_run_at, last_fire, description_text,
created_by, created_at, updated_at, version};Webhook 地址和密钥加密存放,接口只给主机和末 4 位
- Fire:{id, fire_at, kind, status, run_id, run_status, reason, notified: [{event, status, at}], created_at}
- 创建、修改时校验 cron、时区、工作流存在、正式运行要有发布版本、输入模板;不对回 422 {detail(中文), field},
说明里不回显 Python 的值;群机器人的地址要配它平台的格式
- PATCH 带 version,对不上回 409(两人同时保存时靠条件 UPDATE);webhook.url 省略表示不改,空串表示删除
Webhook;看值变没变:整份表单回写不重新校验没改的工作流,也不动到点了的下一次触发
- 预览下几次触发和渲染后的输入;变量清单;立即运行(同一套重叠规则);触发历史翻页;测试 Webhook
工作流、任务名、地址、密钥都是虚构的。
"""
from __future__ import annotations
import asyncio
import json
import uuid
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from zoneinfo import ZoneInfo
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete, func, select
from app.core.crypto import decrypt, encrypt
from app.db.base import SessionLocal
from app.db.models import Run, Schedule, ScheduleFire, WebhookDelivery, Workflow, WorkflowVersion
from app.api import schedules as schedules_api
from app.engine.runner import run_manager
from app.main import app
from app.scheduler import loop, webhook
UTC = timezone.utc
HOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/7c6b5a49-dddd-eeee-ffff-1234567890ab"
SECRET = "SECdemo-api-not-real-7"
SCHEDULE_KEYS = {"id", "name", "workflow_id", "workflow_name", "cron", "timezone", "run_class", "input_template",
"enabled", "overlap", "misfire_grace_s", "webhook", "next_run_at", "last_fire", "description_text",
"created_by", "created_at", "updated_at", "version"}
FIRE_KEYS = {"id", "fire_at", "kind", "status", "run_id", "run_status", "reason", "notified", "compare", "created_at"}
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
async with SessionLocal() as session:
await session.execute(delete(Schedule))
await session.commit()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def graph(*fields: dict) -> dict:
return {"nodes": [node("start", "input", fields=list(fields)),
node("out", "output", fields=[{"name": "r", "value": "完成 {{ input.stat_date }}"}])],
"edges": [{"source": "start", "target": "out"}]}
DATE_FIELD = {"name": "stat_date", "required": True}
async def workflow(*, published: bool = True, fields=(DATE_FIELD,)) -> tuple[str, str]:
name = f"门店周报-{uuid.uuid4().hex[:6]}"
async with SessionLocal() as session:
wf = Workflow(name=name, graph=graph(*fields), status="published" if published else "draft",
published_version=1 if published else None)
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph(*fields)))
await session.commit()
return wf.id, name
def payload(wf_id: str, **over) -> dict:
base = {"name": "周一早报", "workflow_id": wf_id, "cron": "0 9 * * 1", "timezone": "Asia/Shanghai",
"run_class": "formal", "input_template": {"stat_date": "{{ schedule.yesterday }}"}, "enabled": True,
"overlap": "skip", "misfire_grace_s": 3600,
"webhook": {"format": "feishu", "url": HOOK, "secret": SECRET, "events": ["finished", "failed"]}}
base.update(over)
return base
async def create(client, wf_id: str, **over) -> dict:
res = await client.post("/api/schedules", json=payload(wf_id, **over),
headers={"X-Actor": quote("李小明")})
assert res.status_code == 201, res.text
return res.json()
async def settle(run_id: str) -> None:
for _ in range(400):
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed", "cancelled") and row.manifest_seq is not None:
await run_manager.wait_idle(run_id)
return
await asyncio.sleep(0.03)
raise AssertionError(f"运行 {run_id} 停在 {row.status}")
# --------------------------------------------------------------------------
# 增删改查
# --------------------------------------------------------------------------
async def test_create_returns_the_agreed_shape_and_hides_the_address(client):
wf, wf_name = await workflow()
res = await client.post("/api/schedules", json=payload(wf), headers={"X-Actor": quote("李小明")})
assert res.status_code == 201
body = res.json()
assert set(body) == SCHEDULE_KEYS
assert body["workflow_name"] == wf_name and body["created_by"] == "李小明" and body["version"] == 1
assert body["webhook"] == {"format": "feishu", "url_hint": "open.feishu.cn/…90ab", "has_secret": True,
"events": ["finished", "failed"]}
assert body["description_text"] == "每周一 09:00(Asia/Shanghai)"
assert body["last_fire"] is None and body["overlap"] == "skip" and body["misfire_grace_s"] == 3600
next_at = datetime.fromisoformat(body["next_run_at"])
assert next_at.utcoffset().total_seconds() == 0 and next_at.astimezone(UTC).hour == 1
assert next_at > datetime.now(UTC)
# 地址、密钥一个字都不回到浏览器
assert "7c6b5a49" not in res.text and SECRET not in res.text and "/hook/" not in res.text
async with SessionLocal() as session:
row = await session.get(Schedule, body["id"])
assert row.webhook_url.startswith("enc::") and decrypt(row.webhook_url) == HOOK
assert row.webhook_secret.startswith("enc::") and decrypt(row.webhook_secret) == SECRET
async def test_list_and_get(client):
wf, _ = await workflow()
made = await create(client, wf)
listed = (await client.get("/api/schedules")).json()
assert set(listed) == {"items"} and [s["id"] for s in listed["items"]] == [made["id"]]
got = await client.get(f"/api/schedules/{made['id']}")
assert got.status_code == 200 and got.json() == made
async def test_patch_updates_fields_and_bumps_the_version(client):
wf, _ = await workflow()
made = await create(client, wf)
res = await client.patch(f"/api/schedules/{made['id']}",
json={"name": "每日早报", "cron": "30 8 * * *", "version": 1})
assert res.status_code == 200, res.text
body = res.json()
assert (body["name"], body["cron"], body["version"]) == ("每日早报", "30 8 * * *", 2)
assert body["description_text"] == "每天 08:30(Asia/Shanghai)"
assert datetime.fromisoformat(body["next_run_at"]).astimezone(UTC).minute == 30
# 停用:没有下一次
paused = (await client.patch(f"/api/schedules/{made['id']}", json={"enabled": False, "version": 2})).json()
assert paused["enabled"] is False and paused["next_run_at"] is None and paused["version"] == 3
resumed = (await client.patch(f"/api/schedules/{made['id']}", json={"enabled": True, "version": 3})).json()
assert resumed["next_run_at"] is not None
async def test_stale_version_is_a_conflict(client):
wf, _ = await workflow()
made = await create(client, wf)
assert (await client.patch(f"/api/schedules/{made['id']}", json={"name": "甲", "version": 1})).status_code == 200
res = await client.patch(f"/api/schedules/{made['id']}", json={"name": "乙", "version": 1})
assert res.status_code == 409 and "刷新" in res.json()["detail"]
missing = await client.patch(f"/api/schedules/{made['id']}", json={"name": "丙"})
assert missing.status_code == 422 and missing.json()["field"] == "version"
assert (await client.get(f"/api/schedules/{made['id']}")).json()["name"] == "甲"
async def test_concurrent_saves_of_the_same_version_conflict(client, monkeypatch):
"""两个人同时读到第 1 版、同时保存:读后比对都过了,只有先写进去的那份生效,后到的回 409、不覆盖先到的。
这一条靠 UPDATE … WHERE version = 读到的版本 守住,串行的两次请求测不到它"""
wf, _ = await workflow(fields=(DATE_FIELD, {"name": "region"}))
made = await create(client, wf)
sid = made["id"]
real = schedules_api._check_workflow
async def other_save_lands_first(*args, **kwargs):
# 本次请求读完、比对完版本之后,另一个人的保存先提交了
async with SessionLocal() as session:
await session.execute(Schedule.__table__.update().where(Schedule.id == sid)
.values(name="先到的", version=Schedule.version + 1))
await session.commit()
return await real(*args, **kwargs)
monkeypatch.setattr(schedules_api, "_check_workflow", other_save_lands_first)
template = {"stat_date": "{{ schedule.yesterday }}", "region": "华东"}
res = await client.patch(f"/api/schedules/{sid}", json={"name": "后到的", "input_template": template, "version": 1})
assert res.status_code == 409 and "刷新" in res.json()["detail"]
got = (await client.get(f"/api/schedules/{sid}")).json()
assert (got["name"], got["input_template"], got["version"]) == ("先到的", made["input_template"], 2)
EDITABLE = ("name", "workflow_id", "cron", "timezone", "run_class", "input_template", "enabled", "overlap",
"misfire_grace_s")
def full_form(made: dict, **over) -> dict:
"""编辑表单整份回写:没改的字段也原样带上"""
return {**{k: made[k] for k in EDITABLE}, "version": made["version"], **over}
async def test_full_form_save_can_pause_after_the_workflow_is_gone(client):
"""工作流删掉了、或撤了发布:整份带回来、只把启用关掉,也要能停下来。没改的工作流、运行类别、输入不重新校验"""
wf, _ = await workflow()
made = await create(client, wf, webhook=None)
async with SessionLocal() as session:
await session.execute(delete(WorkflowVersion).where(WorkflowVersion.workflow_id == wf))
await session.execute(delete(Workflow).where(Workflow.id == wf))
await session.commit()
res = await client.patch(f"/api/schedules/{made['id']}", json=full_form(made, enabled=False))
assert res.status_code == 200, res.text
assert res.json()["enabled"] is False and res.json()["next_run_at"] is None
# 真改了工作流相关的项,照样校验
res = await client.patch(f"/api/schedules/{made['id']}",
json={"input_template": {"stat_date": "{{ schedule.today }}"}, "version": 2})
assert res.status_code == 422 and res.json()["field"] == "workflow_id"
async def test_full_form_save_keeps_a_due_fire(client):
"""到点了、这一拍还没来:整份带回、只改名字,下一次触发时间不动,这一拍照常发起。以前只要带了 cron、时区、
启用就从现在重算,这一次连记录都没有就丢了"""
wf, _ = await workflow()
made = await create(client, wf, webhook=None, cron="* * * * *")
due = datetime.now(UTC).replace(second=0, microsecond=0)
async with SessionLocal() as session:
await session.execute(Schedule.__table__.update().where(Schedule.id == made["id"]).values(next_run_at=due))
await session.commit()
res = await client.patch(f"/api/schedules/{made['id']}", json=full_form(made, name="每分钟早报"))
assert res.status_code == 200, res.text
assert datetime.fromisoformat(res.json()["next_run_at"]) == due
[fire] = await loop.fire_due()
assert (fire.status, fire.fire_at) == ("started", due)
await settle(fire.run_id)
async def test_changed_cron_keeps_a_due_fire_it_still_covers(client):
"""改了 cron:从改之前那个到点的时刻接着算。新表达式也覆盖那一刻(每分钟 → 工作日每分钟)就还发起这一次;
只属于旧表达式的到点时刻不再发起"""
wf, _ = await workflow()
made = await create(client, wf, webhook=None, cron="* * * * *")
due = datetime.now(UTC).replace(second=0, microsecond=0)
async with SessionLocal() as session:
await session.execute(Schedule.__table__.update().where(Schedule.id == made["id"]).values(next_run_at=due))
await session.commit()
weekday = (due.weekday() + 1) % 7 # cron 的周:0 = 周日
covered = await client.patch(f"/api/schedules/{made['id']}",
json={"cron": f"* * * * {weekday}", "timezone": "UTC", "version": 1})
assert datetime.fromisoformat(covered.json()["next_run_at"]) == due
other = (weekday + 1) % 7
moved = await client.patch(f"/api/schedules/{made['id']}", json={"cron": f"* * * * {other}", "version": 2})
assert datetime.fromisoformat(moved.json()["next_run_at"]) > datetime.now(UTC)
async def test_patch_webhook_keeps_or_removes_the_address(client):
wf, _ = await workflow()
made = await create(client, wf)
sid = made["id"]
# 不带 url:地址不变,只改事件(飞书群机器人的地址只能用飞书格式,格式对不上的见 test_known_bot_address_…)
res = await client.patch(f"/api/schedules/{sid}", json={
"webhook": {"format": "feishu", "events": ["awaiting_approval"]}, "version": 1})
assert res.status_code == 200, res.text
assert res.json()["webhook"] == {"format": "feishu", "url_hint": "open.feishu.cn/…90ab", "has_secret": True,
"events": ["awaiting_approval"]}
async with SessionLocal() as session:
assert decrypt((await session.get(Schedule, sid)).webhook_url) == HOOK
# 密钥写空串:去掉签名
res = await client.patch(f"/api/schedules/{sid}", json={"webhook": {"secret": ""}, "version": 2})
assert res.json()["webhook"]["has_secret"] is False
# url 写空串:删除整个 Webhook
res = await client.patch(f"/api/schedules/{sid}", json={"webhook": {"url": ""}, "version": 3})
assert res.json()["webhook"] is None
async with SessionLocal() as session:
row = await session.get(Schedule, sid)
assert (row.webhook_url, row.webhook_secret, row.webhook_format) == (None, None, None)
# 没有地址时只给格式不行
res = await client.patch(f"/api/schedules/{sid}", json={"webhook": {"format": "wecom"}, "version": 4})
assert res.status_code == 422 and res.json()["field"] == "webhook"
# 重新配上
res = await client.patch(f"/api/schedules/{sid}", json={
"webhook": {"format": "wecom", "url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=abc-0000-9f9f"},
"version": 4})
assert res.json()["webhook"] == {"format": "wecom", "url_hint": "qyapi.weixin.qq.com/…9f9f", "has_secret": False,
"events": ["finished", "failed", "awaiting_approval"]}
async def test_webhook_null_removes_it(client):
wf, _ = await workflow()
made = await create(client, wf)
res = await client.patch(f"/api/schedules/{made['id']}", json={"webhook": None, "version": 1})
assert res.json()["webhook"] is None
async def test_delete(client):
wf, _ = await workflow()
made = await create(client, wf)
sid = made["id"]
ran = (await client.post(f"/api/schedules/{sid}/run")).json()
await settle(ran["run_id"])
res = await client.delete(f"/api/schedules/{sid}")
assert res.status_code == 204
gone = await client.get(f"/api/schedules/{sid}")
assert gone.status_code == 404 and gone.json()["detail"] == "定时任务不存在,可能已被删除"
async with SessionLocal() as session:
assert await session.scalar(select(func.count()).select_from(ScheduleFire)
.where(ScheduleFire.schedule_id == sid)) == 0
# 运行记录留着,来源 id 也留着:它是追溯凭证
assert (await session.get(Run, ran["run_id"])).schedule_id == sid
@pytest.mark.parametrize("method, path", [
("get", ""), ("patch", ""), ("delete", ""), ("post", "/run"), ("get", "/fires"), ("post", "/webhook/test")])
async def test_unknown_schedule_is_404_in_chinese(client, method, path):
kwargs = {"json": {"version": 1}} if method == "patch" else {}
res = await getattr(client, method)(f"/api/schedules/nope{uuid.uuid4().hex[:6]}{path}", **kwargs)
assert res.status_code == 404 and res.json()["detail"] == "定时任务不存在,可能已被删除"
# --------------------------------------------------------------------------
# 校验:422 {detail, field}
# --------------------------------------------------------------------------
@pytest.mark.parametrize("over, field, fragment", [
({"name": " "}, "name", "名称"),
({"cron": "0 25 * * *"}, "cron", "「时」"),
({"cron": "0 0 30 2 *"}, "cron", "永远不会触发"),
({"timezone": "Mars/Olympus"}, "timezone", "时区"),
({"run_class": "weekly"}, "run_class", "正式运行"),
({"input_template": {"stat_date": "{{ schedule.yesterdy }}"}}, "input_template", "schedule.yesterdy"),
({"input_template": {"stat_dat": "{{ schedule.yesterday }}"}}, "input_template", "stat_dat"),
({"input_template": {}}, "input_template", "必填"),
({"misfire_grace_s": -1}, "misfire_grace_s", "宽限"),
({"misfire_grace_s": True}, "misfire_grace_s", "宽限"),
({"overlap": "queue"}, "overlap", "跳过"),
({"enabled": "yes"}, "enabled", "启用"),
({"webhook": {"format": "slack", "url": HOOK}}, "webhook", "格式"),
({"webhook": {"format": "feishu", "url": "open.feishu.cn/hook"}}, "webhook", "Webhook 地址"),
({"webhook": {"format": "feishu", "url": HOOK, "events": ["done"]}}, "webhook", "通知事件"),
({"workflow_id": ""}, "workflow_id", "工作流"),
])
async def test_invalid_create_is_422_with_field(client, over, field, fragment):
wf, _ = await workflow()
res = await client.post("/api/schedules", json=payload(wf, **over))
assert res.status_code == 422, res.text
body = res.json()
assert body["field"] == field and fragment in body["detail"], body
@pytest.mark.parametrize("over, field, says, never", [
# 表单清空、没选时传 null:说「未填写」,不回显 Python 的 None
({"run_class": None}, "run_class", "未填写", "None"),
({"misfire_grace_s": None}, "misfire_grace_s", "未填写", "None"),
({"enabled": None}, "enabled", "未填写", "None"),
({"misfire_grace_s": True}, "misfire_grace_s", "宽限", "True"),
# 列表不原样回显,点出不认识的那一项
({"webhook": {"format": "feishu", "url": HOOK, "events": ["finished", "done"]}}, "webhook", "「done」", "['"),
# 格式写中文名,不列英文枚举
({"webhook": {"format": "slack", "url": HOOK}}, "webhook", "通用、飞书、企业微信、钉钉", "generic"),
])
async def test_invalid_values_are_described_without_python_reprs(client, over, field, says, never):
wf, _ = await workflow()
res = await client.post("/api/schedules", json=payload(wf, **over))
assert res.status_code == 422 and res.json()["field"] == field
detail = res.json()["detail"]
assert says in detail and never not in detail, detail
FEISHU_BOT = HOOK
WECOM_BOT = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=0a1b2c3d-4444-5555-6666-777788889999"
DINGTALK_BOT = "https://oapi.dingtalk.com/robot/send?access_token=0123456789abcdef0123456789abcdef"
@pytest.mark.parametrize("fmt, url, platform", [
("generic", FEISHU_BOT, "飞书"),
("dingtalk", FEISHU_BOT, "飞书"),
("feishu", WECOM_BOT, "企业微信"),
("generic", DINGTALK_BOT, "钉钉"),
])
async def test_known_bot_address_needs_its_own_format(client, fmt, url, platform):
"""群机器人只认自己平台的消息格式:格式选错了,平台回错误码、群里收不到。保存时就说出来"""
wf, _ = await workflow()
hook = {"format": fmt, "url": url, "events": ["failed"]}
res = await client.post("/api/schedules", json=payload(wf, webhook=hook))
assert res.status_code == 422 and res.json()["field"] == "webhook"
assert f"{platform}群机器人" in res.json()["detail"] and f"「{platform}」" in res.json()["detail"]
assert "0123456789abcdef" not in res.text and "0a1b2c3d" not in res.text
async def test_format_follows_the_address_when_left_out(client):
wf, _ = await workflow()
made = await create(client, wf, webhook={"url": WECOM_BOT, "events": ["failed"]})
assert made["webhook"]["format"] == "wecom"
custom = await create(client, wf, webhook={"url": "https://hooks.example.com/agentlab/inbox-7f3a"})
assert custom["webhook"]["format"] == "generic"
# 改格式(地址不变)也要和地址对得上;换了地址、没给格式,按新地址认
res = await client.patch(f"/api/schedules/{made['id']}", json={"webhook": {"format": "feishu"}, "version": 1})
assert res.status_code == 422 and "企业微信群机器人" in res.json()["detail"]
res = await client.patch(f"/api/schedules/{made['id']}", json={"webhook": {"url": DINGTALK_BOT}, "version": 1})
assert res.status_code == 200 and res.json()["webhook"]["format"] == "dingtalk"
async def test_old_mismatch_does_not_block_unrelated_edits(client):
"""库里已有对不上的设置(这条校验之前存的):只改名字、停用,不因为 Webhook 被拦"""
wf, _ = await workflow()
made = await create(client, wf)
async with SessionLocal() as session:
await session.execute(Schedule.__table__.update().where(Schedule.id == made["id"])
.values(webhook_format="generic"))
await session.commit()
res = await client.patch(f"/api/schedules/{made['id']}", json={
"enabled": False, "webhook": {"format": "generic", "events": ["finished", "failed"]}, "version": 1})
assert res.status_code == 200, res.text
@pytest.mark.parametrize("url", [
"https://hooks.example.com:abc/inbox-7f3a", "https://hooks.exa\u200bmple.com/inbox-7f3a"])
async def test_unsendable_address_is_refused_on_save(client, url):
wf, _ = await workflow()
res = await client.post("/api/schedules", json=payload(wf, webhook={"format": "generic", "url": url}))
assert res.status_code == 422 and res.json()["field"] == "webhook"
assert res.json()["detail"].startswith("Webhook 地址")
async def test_webhook_test_with_a_saved_unsendable_address_answers_in_chinese(client):
"""校验加严之前存进库的地址:测试接口回 200 和中文说明,不是 500"""
wf, _ = await workflow()
made = await create(client, wf, webhook=None)
async with SessionLocal() as session:
await session.execute(Schedule.__table__.update().where(Schedule.id == made["id"]).values(
webhook_format="generic", webhook_url=encrypt("https://hooks.example.com:abc/inbox-7f3a"),
webhook_events=["failed"]))
await session.commit()
res = await client.post(f"/api/schedules/{made['id']}/webhook/test")
assert res.status_code == 200, res.text
body = res.json()
assert body["ok"] is False and body["http_status"] is None
assert "hooks.example.com" in body["message"] and "7f3a" not in body["message"]
async def test_missing_workflow_and_unpublished_formal(client):
res = await client.post("/api/schedules", json=payload(f"gone{uuid.uuid4().hex[:6]}"))
assert res.status_code == 422 and res.json()["field"] == "workflow_id" and "不存在" in res.json()["detail"]
wf, name = await workflow(published=False)
res = await client.post("/api/schedules", json=payload(wf))
assert res.status_code == 422 and res.json()["field"] == "run_class"
assert f"「{name}」还没有发布版本" in res.json()["detail"]
# 探索运行不要求发布版本
res = await client.post("/api/schedules", json=payload(wf, run_class="exploratory"))
assert res.status_code == 201
async def test_non_json_body_is_422_in_chinese(client):
res = await client.post("/api/schedules", content=b"not json", headers={"Content-Type": "application/json"})
assert res.status_code == 422 and "JSON" in res.json()["detail"]
res = await client.post("/api/schedules", json=["列表"])
assert res.status_code == 422 and "JSON" in res.json()["detail"]
async def test_webhook_with_empty_url_on_create_means_none(client):
wf, _ = await workflow()
made = await create(client, wf, webhook={"format": "feishu", "url": "", "events": ["failed"]})
assert made["webhook"] is None
# --------------------------------------------------------------------------
# 预览、变量
# --------------------------------------------------------------------------
async def test_preview(client):
res = await client.post("/api/schedules/preview", json={
"cron": "0 9 * * *", "timezone": "Asia/Shanghai", "n": 3,
"input_template": {"stat_date": "{{ schedule.yesterday }}", "since": "{{ schedule.last_fire_time }}"}})
assert res.status_code == 200, res.text
body = res.json()
assert set(body) == {"description_text", "next", "inputs"}
assert body["description_text"] == "每天 09:00(Asia/Shanghai)"
moments = [datetime.fromisoformat(t) for t in body["next"]]
assert len(moments) == 3 and all(m.utcoffset().total_seconds() == 0 for m in moments)
assert all((b - a).total_seconds() == 86400 for a, b in zip(moments, moments[1:]))
first_local = moments[0].astimezone(ZoneInfo("Asia/Shanghai"))
assert body["inputs"]["stat_date"] == (first_local.date() - timedelta(days=1)).isoformat()
assert body["inputs"]["since"].endswith("09:00:00+08:00")
async def test_preview_defaults_to_five(client):
body = (await client.post("/api/schedules/preview", json={"cron": "*/10 * * * *", "timezone": "UTC"})).json()
assert len(body["next"]) == 5 and body["inputs"] == {}
@pytest.mark.parametrize("given, field", [
({"cron": "bad", "timezone": "UTC"}, "cron"),
({"cron": "0 9 * * *", "timezone": "Nowhere/City"}, "timezone"),
({"cron": "0 9 * * *", "timezone": "UTC", "input_template": {"d": "{{ schedule.today"}}, "input_template"),
])
async def test_preview_errors(client, given, field):
res = await client.post("/api/schedules/preview", json=given)
assert res.status_code == 422 and res.json()["field"] == field
assert any("一" <= ch <= "鿿" for ch in res.json()["detail"])
async def test_preview_checks_field_names_against_the_workflow_but_not_missing_ones(client):
wf, _ = await workflow(fields=(DATE_FIELD, {"name": "region"}))
ok = await client.post("/api/schedules/preview", json={
"cron": "0 9 * * *", "timezone": "UTC", "workflow_id": wf, "input_template": {"region": "华东"}})
assert ok.status_code == 200
bad = await client.post("/api/schedules/preview", json={
"cron": "0 9 * * *", "timezone": "UTC", "workflow_id": wf, "input_template": {"regoin": "华东"}})
assert bad.status_code == 422 and bad.json()["field"] == "input_template" and "regoin" in bad.json()["detail"]
async def test_variables(client):
res = await client.get("/api/schedules/variables")
assert res.status_code == 200
items = res.json()
assert len(items) == 10 and all(set(i) == {"name", "label", "example"} for i in items)
assert {"name": "schedule.yesterday", "label": "昨天(YYYY-MM-DD)"}.items() <= next(
i for i in items if i["name"] == "schedule.yesterday").items()
assert (await client.get("/api/schedules/variables", params={"timezone": "Nowhere/City"})).status_code == 422
# --------------------------------------------------------------------------
# 立即运行、触发历史
# --------------------------------------------------------------------------
async def test_run_now_and_overlap(client, monkeypatch):
wf, _ = await workflow()
made = await create(client, wf, webhook=None)
res = await client.post(f"/api/schedules/{made['id']}/run", headers={"X-Actor": quote("王芳")})
assert res.status_code == 200, res.text
body = res.json()
assert set(body) == {"fire", "run_id"} and set(body["fire"]) == FIRE_KEYS
fire = body["fire"]
assert (fire["kind"], fire["status"], fire["run_id"]) == ("manual", "started", body["run_id"])
assert fire["run_status"] in ("queued", "running", "succeeded")
async with SessionLocal() as session:
run = await session.get(Run, body["run_id"])
assert run.schedule_id == made["id"] and run.started_by == "王芳" and run.run_class == "formal"
assert set(run.input) == {"stat_date"}
# 上一次还没结束(这里把它改成停在审批):同一套重叠规则
await settle(body["run_id"])
async with SessionLocal() as session:
await session.execute(Run.__table__.update().where(Run.id == body["run_id"]).values(status="interrupted"))
await session.commit()
# 换到下一秒再点(同一秒内连点交回同一条,见下一个用例)
monkeypatch.setattr(schedules_api, "utcnow", lambda: datetime.now(UTC) + timedelta(seconds=2))
second = (await client.post(f"/api/schedules/{made['id']}/run")).json()
assert (second["fire"]["status"], second["run_id"]) == ("skipped_overlap", None)
assert f"上一次运行 #{body['run_id'][:6]} " in second["fire"]["reason"]
# 运行列表能按定时任务筛
listed = (await client.get("/api/runs", params={"schedule_id": made["id"]})).json()
assert [r["id"] for r in listed] == [body["run_id"]]
async def test_double_click_in_the_same_second_returns_the_same_fire(client, monkeypatch):
wf, _ = await workflow()
made = await create(client, wf, webhook=None)
moment = datetime.now(UTC).replace(microsecond=0)
monkeypatch.setattr(schedules_api, "utcnow", lambda: moment)
first, second = [(await client.post(f"/api/schedules/{made['id']}/run")).json() for _ in range(2)]
assert first["fire"]["id"] == second["fire"]["id"] and first["run_id"] == second["run_id"]
async with SessionLocal() as session:
assert await session.scalar(select(func.count()).select_from(Run).where(Run.schedule_id == made["id"])) == 1
await settle(first["run_id"])
async def test_a_fire_being_launched_is_not_shown_as_failed(client, monkeypatch):
"""发起要先记一条占位再调 launch_run。以前占位写成「未能发起:服务在发起运行前停止」,这期间来读列表、触发
历史的会闪出一次假的失败;进程还在、只是还没发起完的,按已发起给(run_id 还是空的)"""
from app.engine import launch
real = launch.launch_run
halfway = asyncio.Event()
go_on = asyncio.Event()
async def slow(*args, **kwargs):
halfway.set()
await go_on.wait()
return await real(*args, **kwargs)
monkeypatch.setattr(launch, "launch_run", slow)
wf, _ = await workflow()
made = await create(client, wf, webhook=None)
pressing = asyncio.create_task(client.post(f"/api/schedules/{made['id']}/run"))
await asyncio.wait_for(halfway.wait(), timeout=5)
last = (await client.get("/api/schedules")).json()["items"][0]["last_fire"]
assert (last["status"], last["reason"], last["run_id"]) == ("started", None, None)
[item] = (await client.get(f"/api/schedules/{made['id']}/fires")).json()["items"]
assert (item["status"], item["reason"]) == ("started", None)
go_on.set()
done = (await pressing).json()
assert done["fire"]["status"] == "started" and done["run_id"]
await settle(done["run_id"])
async def test_run_now_refused_for_unpublished_formal(client):
wf, name = await workflow()
made = await create(client, wf, webhook=None)
async with SessionLocal() as session:
await session.execute(Workflow.__table__.update().where(Workflow.id == wf).values(published_version=None))
await session.commit()
body = (await client.post(f"/api/schedules/{made['id']}/run")).json()
assert (body["fire"]["status"], body["run_id"]) == ("failed_to_start", None)
assert body["fire"]["reason"] == f"「{name}」还没有发布版本,请先在编排页发布"
async def test_fire_history_pages_and_last_fire(client):
wf, _ = await workflow()
made = await create(client, wf, webhook=None)
sid = made["id"]
async with SessionLocal() as session:
for i in range(5):
session.add(ScheduleFire(schedule_id=sid, fire_at=datetime(2026, 9, 1 + i, 1, 0, tzinfo=UTC),
kind="scheduled", status="skipped_overlap", reason=f"第 {i} 次",
created_at=datetime(2026, 9, 1 + i, 1, 0, 1, tzinfo=UTC)))
await session.commit()
newest = (await session.execute(select(ScheduleFire).where(ScheduleFire.schedule_id == sid)
.order_by(ScheduleFire.created_at.desc()))).scalars().first()
session.add(WebhookDelivery(schedule_id=sid, fire_id=newest.id, event="failed", status="ok", attempts=1,
http_status=200, created_at=datetime(2026, 9, 5, 1, 0, 5, tzinfo=UTC)))
await session.commit()
page1 = (await client.get(f"/api/schedules/{sid}/fires", params={"limit": 2})).json()
assert [f["reason"] for f in page1["items"]] == ["第 4 次", "第 3 次"] and page1["next_before"]
assert page1["items"][0]["notified"] == [{"event": "failed", "status": "ok", "at": "2026-09-05T01:00:05+00:00"}]
assert page1["items"][0]["fire_at"] == "2026-09-05T01:00:00+00:00"
page2 = (await client.get(f"/api/schedules/{sid}/fires",
params={"limit": 2, "before": page1["next_before"]})).json()
assert [f["reason"] for f in page2["items"]] == ["第 2 次", "第 1 次"] and page2["next_before"]
page3 = (await client.get(f"/api/schedules/{sid}/fires",
params={"limit": 2, "before": page2["next_before"]})).json()
assert [f["reason"] for f in page3["items"]] == ["第 0 次"] and page3["next_before"] is None
last = (await client.get(f"/api/schedules/{sid}")).json()["last_fire"]
assert last["reason"] == "第 4 次" and set(last) == FIRE_KEYS
bad = await client.get(f"/api/schedules/{sid}/fires", params={"limit": "many"})
assert bad.status_code == 422 and bad.json()["field"] == "limit"
bad = await client.get(f"/api/schedules/{sid}/fires", params={"limit": "²"})
assert bad.status_code == 422 and bad.json()["field"] == "limit"
bad = await client.get(f"/api/schedules/{sid}/fires", params={"before": "昨天"})
assert bad.status_code == 422 and bad.json()["field"] == "before"
# --------------------------------------------------------------------------
# 测试 Webhook
# --------------------------------------------------------------------------
async def test_webhook_test_endpoint(client, monkeypatch):
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
return httpx.Response(200, json={"code": 0, "msg": "success"})
monkeypatch.setattr(webhook, "_transport", httpx.MockTransport(handler))
wf, _ = await workflow()
made = await create(client, wf)
res = await client.post(f"/api/schedules/{made['id']}/webhook/test")
assert res.status_code == 200
assert res.json() == {"ok": True, "http_status": 200, "message": "请到接收端确认已收到"}
assert str(seen[0].url) == HOOK and "sign" in json.loads(seen[0].content)
bare = await create(client, wf, webhook=None)
res = await client.post(f"/api/schedules/{bare['id']}/webhook/test")
assert res.status_code == 422 and res.json()["field"] == "webhook" and "Webhook" in res.json()["detail"]