Files
MoviePilot/tests/test_db_workflow_queries.py

236 lines
8.6 KiB
Python

"""
工作流表的查询与状态流转行为。
调度器按「触发类型 + 状态」取工作流,取多了会把用户暂停的流程重新跑起来,
取少了则定时任务永远不触发。状态流转里的 `state != 'P'` 守卫是暂停语义的唯一实现,
`run_count` 的自增必须留在 SQL 侧,否则并发执行会丢计数。
"""
import asyncio
import pytest
from app.db.models.workflow import Workflow
@pytest.fixture(autouse=True)
def _track(db):
"""把工作流表纳入用例级回收。"""
db.watermark(Workflow)
def _flow(name: str, trigger_type: str = "timer", state: str = "W",
run_count: int = 0) -> Workflow:
"""构造一条工作流记录。"""
return Workflow(name=name, description=name, timer="0 * * * *",
trigger_type=trigger_type, state=state, run_count=run_count,
actions=[], flows=[], context={}, execution_state={})
# --------------------------------------------------------------------------- #
# 列表查询
# --------------------------------------------------------------------------- #
def test_list_and_get_by_name_match_async_twins(db):
"""
列举与按名查找的同步、异步结果必须一致。
"""
created = db.add(_flow("wf-name"))
assert Workflow.get_by_name(db.session, "wf-name").id == created.id
assert asyncio.run(Workflow.async_get_by_name(name="wf-name")).id == created.id
assert Workflow.get_by_name(db.session, "wf-missing") is None
sync_ids = sorted(w.id for w in Workflow.list(db.session))
async_ids = sorted(w.id for w in asyncio.run(Workflow.async_list()))
assert sync_ids == async_ids
def test_enabled_workflows_exclude_paused(db):
"""
启用列表排除暂停状态。
暂停是用户显式的「别再跑了」,被列出即等于暂停开关失效。
"""
db.add(_flow("wf-waiting", state="W"), _flow("wf-running", state="R"),
_flow("wf-paused", state="P"))
names = {w.name for w in Workflow.get_enabled_workflows(db.session)}
assert {"wf-waiting", "wf-running"} <= names
assert "wf-paused" not in names
assert "wf-paused" not in {w.name for w in
asyncio.run(Workflow.async_get_enabled_workflows())}
def test_timer_triggered_includes_legacy_null_trigger_type(db):
"""
定时触发列表要包含 trigger_type 为空的历史数据。
该列是后加的,老工作流为空;严格等于 'timer' 会让它们从此再也不被调度,
而用户看到的只是「任务不跑了」。
"""
db.add(_flow("wf-timer", trigger_type="timer"),
_flow("wf-legacy", trigger_type=None),
_flow("wf-event", trigger_type="event"),
_flow("wf-timer-paused", trigger_type="timer", state="P"))
names = {w.name for w in Workflow.get_timer_triggered_workflows(db.session)}
assert {"wf-timer", "wf-legacy"} <= names
assert "wf-event" not in names
assert "wf-timer-paused" not in names
def test_event_triggered_requires_explicit_type(db):
"""
事件触发列表只认显式的 'event',且同样排除暂停。
"""
db.add(_flow("wf-event", trigger_type="event"),
_flow("wf-event-paused", trigger_type="event", state="P"),
_flow("wf-legacy", trigger_type=None))
names = {w.name for w in Workflow.get_event_triggered_workflows(db.session)}
assert names >= {"wf-event"}
assert "wf-event-paused" not in names
assert "wf-legacy" not in names
def test_trigger_lists_match_async_twins(db):
"""
定时与事件两条触发列表的同步、异步结果必须一致。
"""
db.add(_flow("wf-t", trigger_type="timer"), _flow("wf-e", trigger_type="event"))
assert sorted(w.id for w in Workflow.get_timer_triggered_workflows(db.session)) == \
sorted(w.id for w in asyncio.run(Workflow.async_get_timer_triggered_workflows()))
assert sorted(w.id for w in Workflow.get_event_triggered_workflows(db.session)) == \
sorted(w.id for w in asyncio.run(Workflow.async_get_event_triggered_workflows()))
# --------------------------------------------------------------------------- #
# 状态流转
# --------------------------------------------------------------------------- #
def test_update_state_and_start_write_the_state(db):
"""
状态更新与启动直接落库,供调度器读到最新状态。
"""
flow = db.add(_flow("wf-state"))
Workflow.update_state(db.session, flow.id, "F")
assert Workflow.get_by_name(db.session, "wf-state").state == "F"
Workflow.start(db.session, flow.id)
assert Workflow.get_by_name(db.session, "wf-state").state == "R"
def test_fail_and_success_respect_the_paused_guard(db):
"""
暂停中的工作流不接受成功/失败结果写入。
守卫丢失后,一个还在跑的旧任务收尾时会把用户刚设的暂停状态改掉,
下一轮调度它又被跑起来。
"""
paused = db.add(_flow("wf-paused", state="P"))
Workflow.fail(db.session, paused.id, "出错了")
assert Workflow.get_by_name(db.session, "wf-paused").state == "P"
Workflow.success(db.session, paused.id, "完成")
assert Workflow.get_by_name(db.session, "wf-paused").state == "P"
def test_fail_records_result_and_timestamp(db):
"""
失败要同时写入结果与最后执行时间,供界面展示失败原因。
"""
flow = db.add(_flow("wf-fail", state="R"))
Workflow.fail(db.session, flow.id, "网络超时")
updated = Workflow.get_by_name(db.session, "wf-fail")
assert (updated.state, updated.result) == ("F", "网络超时")
assert updated.last_time
def test_success_increments_run_count_in_sql(db):
"""
执行次数必须在 SQL 侧自增。
先读后写会在并发执行时丢计数;连续两次成功后必须是 2。
"""
flow = db.add(_flow("wf-count", state="R", run_count=0))
Workflow.success(db.session, flow.id, "第一次")
Workflow.success(db.session, flow.id, "第二次")
updated = Workflow.get_by_name(db.session, "wf-count")
assert updated.run_count == 2
assert updated.state == "S"
def test_reset_clears_progress_and_optionally_the_count(db):
"""
重置清空执行进度;是否清零执行次数由参数决定。
默认保留计数是为了让「重跑」不丢失历史执行统计。
"""
flow = db.add(_flow("wf-reset", state="F", run_count=5))
Workflow.update_current_action(db.session, flow.id, "action-1",
{"k": "v"}, {"step": 1})
Workflow.reset(db.session, flow.id)
kept = Workflow.get_by_name(db.session, "wf-reset")
assert (kept.state, kept.result, kept.current_action) == ("W", None, None)
assert kept.context == {} and kept.execution_state == {}
assert kept.run_count == 5
Workflow.reset(db.session, flow.id, reset_count=True)
assert Workflow.get_by_name(db.session, "wf-reset").run_count == 0
def test_update_current_action_appends_without_duplicating(db):
"""
已执行动作按逗号追加且不重复登记。
重复登记会让「已执行」列表无限膨胀,重跑时的跳过判断也随之失准。
"""
flow = db.add(_flow("wf-action"))
Workflow.update_current_action(db.session, flow.id, "a1", {"n": 1})
Workflow.update_current_action(db.session, flow.id, "a2", {"n": 2})
Workflow.update_current_action(db.session, flow.id, "a1", {"n": 3})
updated = Workflow.get_by_name(db.session, "wf-action")
assert updated.current_action == "a1,a2"
assert updated.context == {"n": 3}
def test_update_current_action_leaves_execution_state_untouched_when_omitted(db):
"""
不传执行状态时保持原值,避免一次进度更新把结构化状态清空。
"""
flow = db.add(_flow("wf-keep-state"))
Workflow.update_current_action(db.session, flow.id, "a1", {}, {"step": 7})
Workflow.update_current_action(db.session, flow.id, "a2", {"n": 1})
assert Workflow.get_by_name(db.session, "wf-keep-state").execution_state == {"step": 7}
def test_update_current_action_matches_async_twin(db):
"""
同步与异步的动作追加必须给出相同的字符串。
"""
sync_flow = db.add(_flow("wf-sync-action"))
async_flow = db.add(_flow("wf-async-action"))
for action in ("a1", "a2", "a1"):
Workflow.update_current_action(db.session, sync_flow.id, action, {})
asyncio.run(Workflow.async_update_current_action(
wid=async_flow.id, action_id=action, context={}))
assert Workflow.get_by_name(db.session, "wf-sync-action").current_action == \
Workflow.get_by_name(db.session, "wf-async-action").current_action