zcbot/tests/test_scheduler.py

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()