187 lines
8.1 KiB
Python
187 lines
8.1 KiB
Python
"""core/scheduler.py 服务层测试(DESIGN §8.5)—— 补审查 Top3 缺口(此前零测试)。
|
|
|
|
覆盖两层:
|
|
- 纯逻辑(无 DB):cron 校验、next_run 时区折算(墙钟语义)、run message 包装
|
|
- DB 级(仅当显式设 ZCBOT_TEST_DB_URL 才跑;只插/删测试专属 user 的行):
|
|
create/claim 推进 next_run(不重复认领)、disabled/软删/过期不认领、
|
|
record_result 连续失败自动停用
|
|
|
|
端到端(守护循环认领 → 真跑 agent → LLM 回复)仍走 scripts/smoke_scheduler.py
|
|
(需起服务 + 真实调用,不进单测)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import unittest
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
def _test_db_ready() -> bool:
|
|
"""DB 级测试只认显式 `ZCBOT_TEST_DB_URL`,绝不回退 .env 的 ZCBOT_DB_URL。
|
|
|
|
教训(2026-07-23 实锤):.env 的 ZCBOT_DB_URL 可能经隧道指向**生产库**(本次
|
|
即 127.0.0.1:6012 → 生产 PG),测试若沿用它,插入的 enabled 到点 job 会被生产
|
|
实例的调度守护认领并真跑 agent。测试库须显式指定:
|
|
set ZCBOT_TEST_DB_URL=postgresql+psycopg://user:pass@127.0.0.1:5432/zcbot_test
|
|
(建库后 `alembic upgrade head` 一次);未设 → 整组 skip。
|
|
"""
|
|
url = os.environ.get("ZCBOT_TEST_DB_URL", "").strip()
|
|
if not url:
|
|
return False
|
|
if "connect_timeout" not in url:
|
|
# 测试库挂了要快速降级 skip,不能让整个 discovery 挂死在 TCP 建连上
|
|
url += ("&" if "?" in url else "?") + "connect_timeout=5"
|
|
os.environ["ZCBOT_DB_URL"] = url # 本测试进程内覆盖,engine 单例随之指向测试库
|
|
return True
|
|
|
|
|
|
from core import scheduler # noqa: E402 (纯逻辑部分不碰 DB,导入安全)
|
|
|
|
try:
|
|
if not _test_db_ready():
|
|
raise RuntimeError("ZCBOT_TEST_DB_URL 未设")
|
|
from core.storage import session_scope
|
|
from core.storage.models import ScheduledJob, User
|
|
|
|
with session_scope() as _s:
|
|
_s.execute(__import__("sqlalchemy").select(1))
|
|
_DB_OK = True
|
|
except Exception:
|
|
_DB_OK = False
|
|
|
|
|
|
class SchedulerPureTests(unittest.TestCase):
|
|
def test_validate_cron(self):
|
|
scheduler.validate_cron("0 8 * * *")
|
|
scheduler.validate_cron("*/5 * * * *")
|
|
for bad in ("", "not a cron", "99 99 * * *", "* * * *"):
|
|
with self.assertRaises(ValueError):
|
|
scheduler.validate_cron(bad)
|
|
|
|
def test_compute_next_run_wall_clock_tz(self):
|
|
"""'0 8 * * *' 是 job 时区的早 8 点,不是 UTC 8 点(§8.5 时区坑)。"""
|
|
# 2026-01-05 00:00 UTC = 上海 08:00 —— 下一个上海 08:00 是 1 月 6 日
|
|
after = datetime(2026, 1, 5, 0, 0, tzinfo=timezone.utc)
|
|
nxt = scheduler.compute_next_run("0 8 * * *", "Asia/Shanghai", after=after)
|
|
self.assertEqual(nxt.tzinfo, timezone.utc)
|
|
self.assertEqual((nxt.year, nxt.month, nxt.day, nxt.hour), (2026, 1, 6, 0))
|
|
# 同一时刻按 UTC 时区算,则当天 08:00 UTC
|
|
nxt_utc = scheduler.compute_next_run("0 8 * * *", "UTC", after=after)
|
|
self.assertEqual((nxt_utc.day, nxt_utc.hour), (5, 8))
|
|
|
|
def test_build_run_message_wraps_prompt(self):
|
|
msg = scheduler.build_run_message(
|
|
{"name": "早安简报", "prompt": "回一句早安", "tz": "Asia/Shanghai"}
|
|
)
|
|
self.assertIn("[定时任务「早安简报」自动触发", msg)
|
|
self.assertIn("回一句早安", msg)
|
|
|
|
|
|
@unittest.skipUnless(_DB_OK, "ZCBOT_TEST_DB_URL 未设或测试库不可达,跳过 DB 级测试(绝不用 .env 的库)")
|
|
class SchedulerDbTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.uid = uuid.uuid4()
|
|
with session_scope() as s:
|
|
s.add(User(user_id=cls.uid, email=f"test-scheduler-{cls.uid.hex[:8]}@invalid.local"))
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
from sqlalchemy import delete
|
|
with session_scope() as s:
|
|
s.execute(delete(ScheduledJob).where(ScheduledJob.user_id == cls.uid))
|
|
s.execute(delete(User).where(User.user_id == cls.uid))
|
|
|
|
def _mk_job(self, **kw) -> uuid.UUID:
|
|
d = scheduler.create_job(
|
|
self.uid, name=kw.pop("name", "t-job"), prompt=kw.pop("prompt", "p"),
|
|
cron=kw.pop("cron", "0 8 * * *"), **kw,
|
|
)
|
|
return uuid.UUID(d["job_id"])
|
|
|
|
def _set(self, jid: uuid.UUID, **values) -> None:
|
|
from sqlalchemy import update
|
|
with session_scope() as s:
|
|
s.execute(update(ScheduledJob).where(ScheduledJob.job_id == jid).values(**values))
|
|
|
|
def _row(self, jid: uuid.UUID) -> ScheduledJob:
|
|
with session_scope() as s:
|
|
return s.get(ScheduledJob, jid)
|
|
|
|
# 认领一律用过去纪元:claim_due_jobs 是全库扫描,若用真实 now 会把真实用户
|
|
# 到点的 job 一并认领(推进 next_run_at 但不执行 = 吃掉一次真实触发)。
|
|
# 测试 job 的 next_run_at 都摆在 2000 年,真实 job(next_run 恒近未来)永不命中。
|
|
EPOCH_DUE = datetime(2000, 1, 1, tzinfo=timezone.utc)
|
|
EPOCH_NOW = datetime(2000, 1, 2, tzinfo=timezone.utc)
|
|
|
|
def _claim_ids(self) -> set:
|
|
return {snap["job_id"] for snap in scheduler.claim_due_jobs(now=self.EPOCH_NOW)}
|
|
|
|
def test_create_sets_next_run_and_validates(self):
|
|
jid = self._mk_job()
|
|
row = self._row(jid)
|
|
self.assertIsNotNone(row.next_run_at)
|
|
self.assertTrue(row.enabled)
|
|
with self.assertRaises(scheduler.JobError):
|
|
scheduler.create_job(self.uid, name="", prompt="p", cron="0 8 * * *")
|
|
with self.assertRaises(ValueError):
|
|
scheduler.create_job(self.uid, name="x", prompt="p", cron="bad cron")
|
|
|
|
def test_claim_advances_next_run_no_double_claim(self):
|
|
jid = self._mk_job(name="due-job")
|
|
self._set(jid, next_run_at=self.EPOCH_DUE)
|
|
self.assertIn(jid, self._claim_ids()) # 到点 → 认领
|
|
row = self._row(jid)
|
|
self.assertGreater(row.next_run_at, self.EPOCH_NOW) # 已按 cron 推进到 now 之后
|
|
self.assertNotIn(jid, self._claim_ids()) # 同一到点不重复认领
|
|
|
|
def test_disabled_and_deleted_not_claimed(self):
|
|
jid = self._mk_job(name="off-job")
|
|
self._set(jid, next_run_at=self.EPOCH_DUE)
|
|
scheduler.set_enabled(self.uid, str(jid), False)
|
|
self.assertNotIn(jid, self._claim_ids())
|
|
|
|
jid2 = self._mk_job(name="del-job")
|
|
self._set(jid2, next_run_at=self.EPOCH_DUE)
|
|
scheduler.cancel_job(self.uid, str(jid2))
|
|
self.assertNotIn(jid2, self._claim_ids())
|
|
self.assertIsNotNone(self._row(jid2).deleted_at) # 软删,不物理删
|
|
|
|
def test_expired_job_auto_disabled(self):
|
|
jid = self._mk_job(name="expired-job")
|
|
self._set(jid, next_run_at=self.EPOCH_DUE,
|
|
expires_at=self.EPOCH_DUE + timedelta(hours=1))
|
|
self.assertNotIn(jid, self._claim_ids()) # 过期不返回
|
|
row = self._row(jid)
|
|
self.assertFalse(row.enabled)
|
|
self.assertEqual(row.last_status, "expired")
|
|
|
|
def test_record_result_failure_streak_auto_disable(self):
|
|
jid = self._mk_job(name="fail-job")
|
|
for i in range(scheduler.FAILURE_DISABLE_THRESHOLD - 1):
|
|
scheduler.record_result(jid, status="error", task_id=None, error=f"boom {i}")
|
|
row = self._row(jid)
|
|
self.assertTrue(row.enabled) # 还差一次,不停
|
|
# 一次 ok 清零计数
|
|
scheduler.record_result(jid, status="ok", task_id=None)
|
|
self.assertEqual(self._row(jid).consecutive_failures, 0)
|
|
# 连续失败到阈值 → 自动停用,last_error 带说明
|
|
for i in range(scheduler.FAILURE_DISABLE_THRESHOLD):
|
|
scheduler.record_result(jid, status="error", task_id=None, error="boom")
|
|
row = self._row(jid)
|
|
self.assertFalse(row.enabled)
|
|
self.assertIn("已自动停用", row.last_error or "")
|
|
|
|
def test_skipped_does_not_touch_counters(self):
|
|
jid = self._mk_job(name="skip-job")
|
|
scheduler.record_result(jid, status="skipped", task_id=None, error="busy")
|
|
row = self._row(jid)
|
|
self.assertEqual(row.run_count or 0, 0)
|
|
self.assertEqual(row.consecutive_failures or 0, 0)
|
|
self.assertEqual(row.last_status, "skipped")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|