"""Tests for cron/scheduler.py — origin resolution, delivery routing, and error logging.""" import json import logging from unittest.mock import patch, MagicMock import pytest from cron.scheduler import _resolve_origin, _resolve_delivery_target, _deliver_result, run_job class TestResolveOrigin: def test_full_origin(self): job = { "origin": { "platform": "telegram", "chat_id": "123456", "chat_name": "Test Chat", "thread_id": "42", } } result = _resolve_origin(job) assert isinstance(result, dict) assert result == job["origin"] assert result["platform"] == "telegram" assert result["chat_id"] == "123456" assert result["chat_name"] == "Test Chat" assert result["thread_id"] == "42" def test_no_origin(self): assert _resolve_origin({}) is None assert _resolve_origin({"origin": None}) is None def test_missing_platform(self): job = {"origin": {"chat_id": "123"}} assert _resolve_origin(job) is None def test_missing_chat_id(self): job = {"origin": {"platform": "telegram"}} assert _resolve_origin(job) is None def test_empty_origin(self): job = {"origin": {}} assert _resolve_origin(job) is None class TestResolveDeliveryTarget: def test_origin_delivery_preserves_thread_id(self): job = { "deliver": "origin", "origin": { "platform": "telegram", "chat_id": "-1001", "thread_id": "17585", }, } assert _resolve_delivery_target(job) == { "platform": "telegram", "chat_id": "-1001", "thread_id": "17585", } def test_bare_platform_uses_matching_origin_chat(self): job = { "deliver": "telegram", "origin": { "platform": "telegram", "chat_id": "-1001", "thread_id": "17585", }, } assert _resolve_delivery_target(job) == { "platform": "telegram", "chat_id": "-1001", "thread_id": "17585", } def test_bare_platform_falls_back_to_home_channel(self, monkeypatch): monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-2002") job = { "deliver": "telegram", "origin": { "platform": "discord", "chat_id": "abc", }, } assert _resolve_delivery_target(job) == { "platform": "telegram", "chat_id": "-2002", "thread_id": None, } class TestDeliverResultMirrorLogging: """Verify that mirror_to_session failures are logged, not silently swallowed.""" def test_mirror_failure_is_logged(self, caplog): """When mirror_to_session raises, a warning should be logged.""" from gateway.config import Platform pconfig = MagicMock() pconfig.enabled = True mock_cfg = MagicMock() mock_cfg.platforms = {Platform.TELEGRAM: pconfig} with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \ patch("asyncio.run", return_value=None), \ patch("gateway.mirror.mirror_to_session", side_effect=ConnectionError("network down")): job = { "id": "test-job", "deliver": "origin", "origin": {"platform": "telegram", "chat_id": "123"}, } with caplog.at_level(logging.WARNING, logger="cron.scheduler"): _deliver_result(job, "Hello!") assert any("mirror_to_session failed" in r.message for r in caplog.records), \ f"Expected 'mirror_to_session failed' warning in logs, got: {[r.message for r in caplog.records]}" def test_origin_delivery_preserves_thread_id(self): """Origin delivery should forward thread_id to send/mirror helpers.""" from gateway.config import Platform pconfig = MagicMock() pconfig.enabled = True mock_cfg = MagicMock() mock_cfg.platforms = {Platform.TELEGRAM: pconfig} job = { "id": "test-job", "deliver": "origin", "origin": { "platform": "telegram", "chat_id": "-1001", "thread_id": "17585", }, } with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \ patch("tools.send_message_tool._send_to_platform", return_value={"success": True}) as send_mock, \ patch("gateway.mirror.mirror_to_session") as mirror_mock, \ patch("asyncio.run", side_effect=lambda coro: None): _deliver_result(job, "hello") send_mock.assert_called_once() assert send_mock.call_args.kwargs["thread_id"] == "17585" mirror_mock.assert_called_once_with( "telegram", "-1001", "hello", source_label="cron", thread_id="17585", ) class TestRunJobSessionPersistence: def test_run_job_passes_session_db_and_cron_platform(self, tmp_path): job = { "id": "test-job", "name": "test", "prompt": "hello", } fake_db = MagicMock() with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ patch("dotenv.load_dotenv"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", return_value={ "api_key": "test-key", "base_url": "https://example.invalid/v1", "provider": "openrouter", "api_mode": "chat_completions", }, ), \ patch("run_agent.AIAgent") as mock_agent_cls: mock_agent = MagicMock() mock_agent.run_conversation.return_value = {"final_response": "ok"} mock_agent_cls.return_value = mock_agent success, output, final_response, error = run_job(job) assert success is True assert error is None assert final_response == "ok" assert "ok" in output kwargs = mock_agent_cls.call_args.kwargs assert kwargs["session_db"] is fake_db assert kwargs["platform"] == "cron" assert kwargs["session_id"].startswith("cron_test-job_") fake_db.close.assert_called_once() class TestRunJobConfigLogging: """Verify that config.yaml parse failures are logged, not silently swallowed.""" def test_bad_config_yaml_is_logged(self, caplog, tmp_path): """When config.yaml is malformed, a warning should be logged.""" bad_yaml = tmp_path / "config.yaml" bad_yaml.write_text("invalid: yaml: [[[bad") job = { "id": "test-job", "name": "test", "prompt": "hello", } with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ patch("dotenv.load_dotenv"), \ patch("run_agent.AIAgent") as mock_agent_cls: mock_agent = MagicMock() mock_agent.run_conversation.return_value = {"final_response": "ok"} mock_agent_cls.return_value = mock_agent with caplog.at_level(logging.WARNING, logger="cron.scheduler"): run_job(job) assert any("failed to load config.yaml" in r.message for r in caplog.records), \ f"Expected 'failed to load config.yaml' warning in logs, got: {[r.message for r in caplog.records]}" def test_bad_prefill_messages_is_logged(self, caplog, tmp_path): """When the prefill messages file contains invalid JSON, a warning should be logged.""" # Valid config.yaml that points to a bad prefill file config_yaml = tmp_path / "config.yaml" config_yaml.write_text("prefill_messages_file: prefill.json\n") bad_prefill = tmp_path / "prefill.json" bad_prefill.write_text("{not valid json!!!") job = { "id": "test-job", "name": "test", "prompt": "hello", } with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ patch("dotenv.load_dotenv"), \ patch("run_agent.AIAgent") as mock_agent_cls: mock_agent = MagicMock() mock_agent.run_conversation.return_value = {"final_response": "ok"} mock_agent_cls.return_value = mock_agent with caplog.at_level(logging.WARNING, logger="cron.scheduler"): run_job(job) assert any("failed to parse prefill messages" in r.message for r in caplog.records), \ f"Expected 'failed to parse prefill messages' warning in logs, got: {[r.message for r in caplog.records]}"