Files
AgentLab/backend/tests/test_scheduler_inputs.py
T
ThearchyHelios bb176f6f9d 定时任务小修:输入模板的字段名去掉首尾空白再存,后台通知任务的异常记日志
- 「 stat_date」原样交给运行,入口节点认不出它、必填项照样报缺:保存时去掉首尾空白,去掉后重名的拦下
- 通知扫描是另起的后台任务、没人 await:出错时异常留在任务里,只会在回收时冒出一句「从未取回的异常」。
  包一层,出错记日志,下一拍照常
2026-10-05 08:47:18 +08:00

190 lines
9.1 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.
"""定时任务的输入模板(app/scheduler/service.py):日期变量按触发时间在任务时区算,模板写错就说出来。
- 变量都按 fire_at(计划触发时间)在任务时区算,不按「现在」:补跑晚了一小时,昨天还是计划那天的昨天
- 跨月、跨年、跨时区(同一时刻在上海已经是周一,在纽约还是周日);周从周一算起
- schedule.last_fire_time 是上一次计划触发的时间(按 cron 往前找),和本次一样写成带偏移的 ISO 时间
- 模板写错(未知变量、未知过滤器、{{ }} 不成对、空的 {{ }}、值不是文本)抛 TemplateError,中文说明哪个输入
- 声明了入口字段的工作流:模板里多出来的字段名、缺的必填输入(没有默认值)都拦下
夹具的工作流、字段名都是虚构的。
"""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from app.scheduler import service
from app.scheduler.service import TemplateError
UTC = timezone.utc
def values(fire_at: datetime, tz: str = "Asia/Shanghai", cron: str = "0 9 * * 1") -> dict[str, str]:
return service.template_values(fire_at, tz, cron)
# --------------------------------------------------------------------------
# 变量按 fire_at 在任务时区算
# --------------------------------------------------------------------------
def test_monday_morning_in_shanghai():
v = values(datetime(2026, 10, 5, 1, 0, tzinfo=UTC)) # 上海 2026-10-05(周一)09:00
assert v == {
"today": "2026-10-05",
"yesterday": "2026-10-04",
"this_week_start": "2026-10-05",
"last_week_start": "2026-09-28",
"last_week_end": "2026-10-04",
"this_month_start": "2026-10-01",
"last_month_start": "2026-09-01",
"last_month_end": "2026-09-30",
"fire_time": "2026-10-05T09:00:00+08:00",
"last_fire_time": "2026-09-28T09:00:00+08:00",
}
def test_across_a_month_boundary():
# 上海 2026-03-01(周日)00:30,UTC 还是 2 月 28 日
v = values(datetime(2026, 2, 28, 16, 30, tzinfo=UTC), cron="30 0 * * *")
assert (v["today"], v["yesterday"]) == ("2026-03-01", "2026-02-28")
assert (v["this_week_start"], v["last_week_start"], v["last_week_end"]) == ("2026-02-23", "2026-02-16", "2026-02-22")
assert (v["this_month_start"], v["last_month_start"], v["last_month_end"]) == ("2026-03-01", "2026-02-01", "2026-02-28")
assert v["last_fire_time"] == "2026-02-28T00:30:00+08:00"
def test_across_a_year_boundary():
v = values(datetime(2027, 1, 1, 0, 0, tzinfo=UTC), cron="0 8 1 * *") # 上海 2027-01-01(周五)08:00
assert (v["today"], v["yesterday"]) == ("2027-01-01", "2026-12-31")
assert (v["this_week_start"], v["last_week_start"], v["last_week_end"]) == ("2026-12-28", "2026-12-21", "2026-12-27")
assert (v["this_month_start"], v["last_month_start"], v["last_month_end"]) == ("2027-01-01", "2026-12-01", "2026-12-31")
assert v["last_fire_time"] == "2026-12-01T08:00:00+08:00"
def test_leap_february_ends_on_the_29th():
v = values(datetime(2028, 3, 1, 1, 0, tzinfo=UTC), cron="0 9 1 * *")
assert (v["last_month_start"], v["last_month_end"]) == ("2028-02-01", "2028-02-29")
def test_same_moment_in_two_timezones():
"""UTC 2026-10-04 20:00:上海已经是周一 04:00,纽约还是周日 16:00"""
moment = datetime(2026, 10, 4, 20, 0, tzinfo=UTC)
shanghai = values(moment, "Asia/Shanghai", "0 4 * * *")
new_york = values(moment, "America/New_York", "0 16 * * *")
assert (shanghai["today"], shanghai["this_week_start"]) == ("2026-10-05", "2026-10-05")
assert (new_york["today"], new_york["this_week_start"]) == ("2026-10-04", "2026-09-28")
assert shanghai["fire_time"] == "2026-10-05T04:00:00+08:00"
assert new_york["fire_time"] == "2026-10-04T16:00:00-04:00"
def test_fire_time_drops_sub_seconds():
"""立即运行的触发时间是点下去的那一刻,写进输入时只到秒"""
v = values(datetime(2026, 10, 5, 3, 4, 5, 678901, tzinfo=UTC))
assert v["fire_time"] == "2026-10-05T11:04:05+08:00"
# --------------------------------------------------------------------------
# 渲染与校验
# --------------------------------------------------------------------------
def test_render_inputs():
v = values(datetime(2026, 10, 5, 1, 0, tzinfo=UTC))
out = service.render_inputs({
"stat_date": "{{ schedule.yesterday }}",
"week": "{{schedule.last_week_start}}~{{ schedule.last_week_end }}",
"region": "华东",
"tag": "{{ schedule.today | upper }}",
}, v)
assert out == {"stat_date": "2026-10-04", "week": "2026-09-28~2026-10-04", "region": "华东", "tag": "2026-10-05"}
def test_valid_templates_pass():
assert service.check_template({"a": "{{ schedule.today }}", "b": "固定值", "c": "{{ schedule.fire_time | trim }}"}) == {
"a": "{{ schedule.today }}", "b": "固定值", "c": "{{ schedule.fire_time | trim }}"}
assert service.check_template(None) == {}
@pytest.mark.parametrize("template, fragments", [
({"stat_date": "{{ schedule.yesterdy }}"}, ["stat_date", "schedule.yesterdy", "schedule.yesterday"]),
({"stat_date": "{{ input.day }}"}, ["stat_date", "input.day"]),
({"stat_date": "{{ schedule.today"}, ["stat_date", "成对"]),
({"stat_date": "schedule.today }}"}, ["stat_date", "成对"]),
({"stat_date": "{{ }}"}, ["stat_date", "空的"]),
({"stat_date": "{{ schedule.today | shout }}"}, ["stat_date", "shout", "过滤器"]),
({"stat_date": 20261005}, ["stat_date", "文本"]),
({"": "{{ schedule.today }}"}, ["字段名"]),
(["{{ schedule.today }}"], ["输入模板"]),
])
def test_bad_templates_explain_in_chinese(template, fragments):
with pytest.raises(TemplateError) as caught:
service.check_template(template)
for fragment in fragments:
assert fragment in str(caught.value), str(caught.value)
def test_field_names_are_trimmed_and_unique():
assert service.check_template({" stat_date ": "{{ schedule.yesterday }}"}) == {"stat_date": "{{ schedule.yesterday }}"}
with pytest.raises(TemplateError) as caught:
service.check_template({"stat_date": "a", "stat_date ": "b"})
assert "stat_date" in str(caught.value) and "两次" in str(caught.value)
def test_variable_catalog():
items = service.variables(datetime(2026, 10, 5, 1, 0, tzinfo=UTC), "Asia/Shanghai")
names = [i["name"] for i in items]
assert names == [f"schedule.{key}" for key in (
"today", "yesterday", "this_week_start", "last_week_start", "last_week_end",
"this_month_start", "last_month_start", "last_month_end", "fire_time", "last_fire_time")]
by_name = {i["name"]: i for i in items}
assert by_name["schedule.yesterday"] == {"name": "schedule.yesterday", "label": "昨天(YYYY-MM-DD)",
"example": "2026-10-04"}
assert all(i["label"] and i["example"] for i in items)
# --------------------------------------------------------------------------
# 和工作流入口字段对照
# --------------------------------------------------------------------------
def graph(*fields: dict) -> dict:
return {
"nodes": [
{"id": "start", "type": "input", "position": {"x": 0, "y": 0},
"data": {"label": "输入", "config": {"fields": list(fields)}}},
{"id": "out", "type": "output", "position": {"x": 300, "y": 0},
"data": {"label": "成果", "config": {"fields": [{"name": "r", "value": "完成"}]}}},
],
"edges": [{"source": "start", "target": "out"}],
}
def test_fields_of_a_graph():
g = graph({"name": "stat_date", "required": True}, {"name": "region", "default": "华东"})
assert [f["name"] for f in service.input_fields(g)] == ["stat_date", "region"]
assert service.input_fields({"nodes": "坏掉的图"}) == []
def test_unknown_field_names_are_refused():
fields = service.input_fields(graph({"name": "stat_date"}, {"name": "region"}))
with pytest.raises(TemplateError) as caught:
service.check_fields({"stat_dat": "{{ schedule.yesterday }}"}, fields, "门店周报")
assert "stat_dat" in str(caught.value) and "门店周报" in str(caught.value) and "stat_date" in str(caught.value)
def test_missing_required_inputs_are_refused_only_when_asked():
fields = service.input_fields(graph({"name": "stat_date", "required": True},
{"name": "region", "required": True, "default": "华东"}))
with pytest.raises(TemplateError) as caught:
service.check_fields({}, fields, "门店周报")
assert "stat_date" in str(caught.value) and "必填" in str(caught.value)
# 预览时还在填,不查缺的;有默认值的必填项不算缺
service.check_fields({}, fields, "门店周报", require=False)
service.check_fields({"stat_date": "{{ schedule.yesterday }}"}, fields, "门店周报")
def test_graph_without_declared_fields_accepts_any_name():
"""入口没声明字段时输入是开放的:运行时传什么键都收"""
service.check_fields({"anything": "x"}, service.input_fields(graph()), "门店周报")