Files
AstrBot/tests/unit/test_cron_manager_edge.py
LIghtJUNction 273b6777cf test: fix unit tests after master→dev merge
- Fix test_long_term_memory.py: Image(file=), Provider spec, config keys
- Fix test_star_manager.py: Metric.upload AsyncMock, os.path.exists patch
- Fix test_kb_helper.py: chunk assertion signature, session.begin mock
- Fix test_file_extract.py: match actual code behavior (AsyncClient created before read)
- Fix test_pipeline_process_stage/respond_stage.py: reload stage modules to avoid stale imports from bootstrap tests
- Fix test_provider_register.py: handle pre-existing registry entries
- Fix test_pipeline_scheduler.py: remove blanket xfail, apply per-test xfail
- Mark 23 lifecycle tests xfail due to state machine changes
- Fix scheduler.py: missing break after generator stage stops propagation
- Frontend build verified
2026-05-08 12:17:58 +08:00

442 lines
16 KiB
Python

"""Supplementary edge-case tests for CronJobManager.
Covers validation branches, interval-based jobs, run-once auto-cleanup,
and failure paths not covered by the main test suite.
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from astrbot.core.cron.manager import CronJobManager, CronJobSchedulingError
from astrbot.core.db.po import CronJob
# ---- Fixtures (self-contained) ----
@pytest.fixture
def mock_db():
db = MagicMock()
db.create_cron_job = AsyncMock()
db.get_cron_job = AsyncMock()
db.update_cron_job = AsyncMock()
db.delete_cron_job = AsyncMock()
db.list_cron_jobs = AsyncMock(return_value=[])
return db
@pytest.fixture
def mock_context():
ctx = MagicMock()
ctx.get_config = MagicMock(return_value={"admins_id": []})
ctx.conversation_manager = MagicMock()
return ctx
@pytest.fixture
def cron_manager(mock_db):
return CronJobManager(mock_db)
# ---- add_basic_job validation edge cases ----
class TestAddBasicJobEdgeCases:
"""Validation and interval-based variants for add_basic_job."""
@pytest.mark.asyncio
async def test_add_basic_job_with_interval_seconds(self, cron_manager, mock_db):
"""interval_seconds can be used instead of cron_expression."""
cron_manager._started = True
job = CronJob(
job_id="interval-job",
name="Interval",
job_type="basic",
payload={"interval_seconds": 300},
)
mock_db.create_cron_job.return_value = job
handler = MagicMock()
with patch.object(cron_manager, "_get_next_run_time", return_value=None):
result = await cron_manager.add_basic_job(
name="Interval",
interval_seconds=300,
handler=handler,
description="Every 5 min",
)
assert result == job
mock_db.create_cron_job.assert_called_once()
# interval should be packed into the payload
call_payload = mock_db.create_cron_job.call_args.kwargs["payload"]
assert "interval_seconds" in call_payload
assert call_payload["interval_seconds"] == 300
@pytest.mark.asyncio
async def test_add_basic_job_both_cron_and_interval_raises(self, cron_manager, mock_db):
"""Providing both cron_expression and interval_seconds raises ValueError."""
handler = MagicMock()
with pytest.raises(ValueError, match="must have exactly one value"):
await cron_manager.add_basic_job(
name="Bad",
cron_expression="0 9 * * *",
interval_seconds=300,
handler=handler,
)
@pytest.mark.asyncio
async def test_add_basic_job_neither_cron_nor_interval_raises(self, cron_manager, mock_db):
"""Providing neither cron_expression nor interval_seconds raises ValueError."""
handler = MagicMock()
with pytest.raises(ValueError, match="must have exactly one value"):
await cron_manager.add_basic_job(
name="Bad",
handler=handler,
)
@pytest.mark.asyncio
async def test_add_basic_job_payload_passed_through(self, cron_manager, mock_db):
"""User-supplied payload is forwarded to create_cron_job."""
cron_manager._started = True
job = CronJob(
job_id="payload-job",
name="Payload",
job_type="basic",
cron_expression="0 9 * * *",
payload={"custom_key": "custom_value"},
)
mock_db.create_cron_job.return_value = job
handler = MagicMock()
user_payload = {"custom_key": "custom_value"}
with patch.object(cron_manager, "_get_next_run_time", return_value=None):
await cron_manager.add_basic_job(
name="Payload",
cron_expression="0 9 * * *",
handler=handler,
payload=user_payload,
)
call_payload = mock_db.create_cron_job.call_args.kwargs["payload"]
assert call_payload["custom_key"] == "custom_value"
@pytest.mark.asyncio
async def test_add_basic_job_non_persistent_not_scheduled(self, cron_manager, mock_db):
"""A disabled non-persistent job is stored but not scheduled."""
job = CronJob(
job_id="np-job", name="NonPersist", job_type="basic", enabled=False
)
mock_db.create_cron_job.return_value = job
handler = MagicMock()
with patch.object(cron_manager, "_schedule_job") as mock_schedule:
result = await cron_manager.add_basic_job(
name="NonPersist",
cron_expression="0 9 * * *",
handler=handler,
persistent=False,
enabled=False,
)
assert result == job
mock_schedule.assert_not_called()
# ---- _run_job full lifecycle edge cases ----
class TestRunJobEdgeCases:
"""Full lifecycle for _run_job covering status transitions and cleanup."""
@pytest.mark.asyncio
async def test_run_job_basic_completed(self, cron_manager, mock_db):
"""A basic job transitions to 'completed' after successful run."""
job = CronJob(
job_id="basic-done",
name="Done",
job_type="basic",
enabled=True,
cron_expression="0 9 * * *",
)
mock_db.get_cron_job.return_value = job
handler = MagicMock(return_value=None)
cron_manager._basic_handlers["basic-done"] = handler
await cron_manager._run_job("basic-done")
# Status should be updated twice: running -> completed
update_calls = [
c for c in mock_db.update_cron_job.call_args_list
]
# At minimum one call with status="running" and one with status="completed"
running_calls = [
c for c in update_calls
if c.kwargs.get("status") == "running"
]
completed_calls = [
c for c in update_calls
if c.kwargs.get("status") == "completed"
]
assert len(running_calls) >= 1, "Expected a 'running' status update"
assert len(completed_calls) >= 1, "Expected a 'completed' status update"
handler.assert_called_once()
@pytest.mark.asyncio
async def test_run_job_basic_failed(self, cron_manager, mock_db):
"""A basic job transitions to 'failed' and records the error."""
mock_db.get_cron_job.return_value = CronJob(
job_id="basic-fail",
name="Fail",
job_type="basic",
enabled=True,
cron_expression="0 9 * * *",
)
# Remove the handler so _run_basic_job raises RuntimeError
if "basic-fail" in cron_manager._basic_handlers:
del cron_manager._basic_handlers["basic-fail"]
await cron_manager._run_job("basic-fail")
# final update should have status="failed" and last_error set
last_call = mock_db.update_cron_job.call_args_list[-1]
assert last_call.kwargs["status"] == "failed"
assert last_call.kwargs["last_error"] is not None
@pytest.mark.asyncio
async def test_run_job_run_once_deletes_after_completion(self, cron_manager, mock_db):
"""A run_once job is deleted after it completes successfully."""
job = CronJob(
job_id="once-job",
name="Once",
job_type="basic",
enabled=True,
run_once=True,
cron_expression="0 9 * * *",
)
mock_db.get_cron_job.return_value = job
handler = MagicMock(return_value=None)
cron_manager._basic_handlers["once-job"] = handler
with patch.object(cron_manager, "delete_job") as mock_delete:
await cron_manager._run_job("once-job")
mock_delete.assert_awaited_once_with("once-job")
@pytest.mark.asyncio
async def test_run_job_active_agent_raises_on_missing_session(self, cron_manager, mock_db):
"""run_job on an active_agent job without session payload raises."""
job = CronJob(
job_id="aa-no-session",
name="NoSession",
job_type="active_agent",
enabled=True,
cron_expression="0 9 * * *",
payload={}, # No "session" key
)
mock_db.get_cron_job.return_value = job
await cron_manager._run_job("aa-no-session")
# Should not crash the manager; error is caught and logged,
# status should be "failed"
last_call = mock_db.update_cron_job.call_args_list[-1]
assert last_call.kwargs["status"] == "failed"
assert "missing session" in (last_call.kwargs.get("last_error") or "").lower()
# ---- update_job and sync_from_db ----
class TestUpdateAndSyncEdgeCases:
"""Edge cases for update_job and sync_from_db."""
@pytest.mark.asyncio
async def test_update_job_reschedules_when_enabled(self, cron_manager, mock_db):
"""update_job re-schedules a job that was previously disabled."""
job_id = "re-enable-job"
updated_job = CronJob(
job_id=job_id,
name="ReEnabled",
job_type="basic",
cron_expression="0 9 * * *",
enabled=True, # Now enabled
)
mock_db.update_cron_job.return_value = updated_job
with patch.object(cron_manager, "_schedule_job") as mock_schedule:
result = await cron_manager.update_job(job_id, enabled=True)
assert result == updated_job
mock_schedule.assert_called_once_with(updated_job)
@pytest.mark.asyncio
async def test_update_job_removes_scheduled_when_disabled(self, cron_manager, mock_db):
"""update_job removes the job from the scheduler when disabled."""
job_id = "disable-job"
updated_job = CronJob(
job_id=job_id,
name="Disabled",
job_type="basic",
cron_expression="0 9 * * *",
enabled=False, # Now disabled, should not schedule
)
mock_db.update_cron_job.return_value = updated_job
with patch.object(cron_manager, "_remove_scheduled") as mock_remove:
with patch.object(cron_manager, "_schedule_job") as mock_schedule:
result = await cron_manager.update_job(job_id, enabled=False)
assert result == updated_job
mock_remove.assert_called_once_with(job_id)
mock_schedule.assert_not_called()
@pytest.mark.asyncio
async def test_sync_from_db_schedules_basic_with_handler(self, cron_manager, mock_db):
"""sync_from_db schedules basic jobs when their handler is registered."""
job = CronJob(
job_id="sync-basic",
name="SyncBasic",
job_type="basic",
cron_expression="0 9 * * *",
enabled=True,
persistent=True,
)
mock_db.list_cron_jobs.return_value = [job]
cron_manager._basic_handlers["sync-basic"] = MagicMock()
with patch.object(cron_manager, "_schedule_job") as mock_schedule:
await cron_manager.sync_from_db()
mock_schedule.assert_called_once_with(job)
@pytest.mark.asyncio
async def test_sync_from_db_skips_basic_without_handler(self, cron_manager, mock_db):
"""sync_from_db skips basic jobs that have no registered handler."""
job = CronJob(
job_id="orphan-basic",
name="Orphan",
job_type="basic",
cron_expression="0 9 * * *",
enabled=True,
persistent=True,
)
mock_db.list_cron_jobs.return_value = [job]
with patch.object(cron_manager, "_schedule_job") as mock_schedule:
await cron_manager.sync_from_db()
mock_schedule.assert_not_called()
@pytest.mark.asyncio
async def test_sync_from_db_schedules_active_agent_without_handler(self, cron_manager, mock_db):
"""Active-agent jobs are scheduled regardless of handler registration."""
job = CronJob(
job_id="sync-active",
name="SyncActive",
job_type="active_agent",
cron_expression="0 9 * * *",
enabled=True,
persistent=True,
)
mock_db.list_cron_jobs.return_value = [job]
with patch.object(cron_manager, "_schedule_job") as mock_schedule:
await cron_manager.sync_from_db()
mock_schedule.assert_called_once_with(job)
# ---- _schedule_job trigger variants ----
class TestScheduleJobTriggers:
"""Trigger type selection in _schedule_job."""
@pytest.mark.asyncio
async def test_schedule_interval_trigger(self, cron_manager, mock_context):
"""_schedule_job creates an IntervalTrigger when payload has interval_seconds."""
job = CronJob(
job_id="interval-trigger",
name="Interval",
job_type="basic",
cron_expression="0 9 * * *",
enabled=True,
payload={"interval_seconds": 600},
)
mock_db = cron_manager.db
mock_db.list_cron_jobs = AsyncMock(return_value=[])
mock_db.update_cron_job = AsyncMock()
await cron_manager.start(mock_context)
cron_manager._schedule_job(job)
aps_job = cron_manager.scheduler.get_job("interval-trigger")
assert aps_job is not None
# The trigger should be an IntervalTrigger
from apscheduler.triggers.interval import IntervalTrigger
assert isinstance(aps_job.trigger, IntervalTrigger)
assert aps_job.trigger.interval.total_seconds() == 600
@pytest.mark.asyncio
async def test_schedule_invalid_cron_raises_scheduling_error(self, cron_manager, mock_context):
"""An invalid cron expression raises CronJobSchedulingError."""
job = CronJob(
job_id="bad-cron",
name="BadCron",
job_type="basic",
cron_expression="not-a-valid-cron",
enabled=True,
)
mock_db = cron_manager.db
mock_db.list_cron_jobs = AsyncMock(return_value=[])
mock_db.update_cron_job = AsyncMock()
await cron_manager.start(mock_context)
with pytest.raises(CronJobSchedulingError):
cron_manager._schedule_job(job)
@pytest.mark.asyncio
async def test_schedule_run_once_without_run_at_raises(self, cron_manager, mock_context):
"""A run_once job without run_at in payload or expression raises CronJobSchedulingError."""
job = CronJob(
job_id="no-run-at",
name="NoRunAt",
job_type="active_agent",
cron_expression=None,
enabled=True,
run_once=True,
payload={},
)
mock_db = cron_manager.db
mock_db.list_cron_jobs = AsyncMock(return_value=[])
mock_db.update_cron_job = AsyncMock()
await cron_manager.start(mock_context)
with pytest.raises(CronJobSchedulingError):
cron_manager._schedule_job(job)
@pytest.mark.asyncio
async def test_schedule_auto_starts_when_not_started(self, cron_manager):
"""_schedule_job auto-starts the scheduler if _started is False."""
assert cron_manager._started is False
job = CronJob(
job_id="auto-start",
name="AutoStart",
job_type="basic",
cron_expression="0 9 * * *",
enabled=True,
)
cron_manager.db.list_cron_jobs = AsyncMock(return_value=[])
cron_manager.db.update_cron_job = AsyncMock()
cron_manager._schedule_job(job)
assert cron_manager._started is True
assert cron_manager.scheduler.get_job("auto-start") is not None