"""Tests for /restart notification — the gateway notifies the requester on comeback.""" import asyncio import json from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest import gateway.run as gateway_run from gateway.config import HomeChannel, Platform from gateway.platforms.base import MessageEvent, MessageType, SendResult from gateway.session import build_session_key from tests.gateway.restart_test_helpers import ( make_restart_runner, make_restart_source, ) # ── restart marker helpers ─────────────────────────────────────────────── def test_restart_notification_pending_false_without_marker(tmp_path, monkeypatch): monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) assert gateway_run._restart_notification_pending() is False def test_restart_notification_pending_true_with_marker(tmp_path, monkeypatch): monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) (tmp_path / ".restart_notify.json").write_text("{}") assert gateway_run._restart_notification_pending() is True # ── _handle_restart_command writes .restart_notify.json ────────────────── @pytest.mark.asyncio async def test_restart_command_writes_notify_file(tmp_path, monkeypatch): """When /restart fires, the requester's routing info is persisted to disk.""" monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) runner, _adapter = make_restart_runner() runner.request_restart = MagicMock(return_value=True) source = make_restart_source(chat_id="42") event = MessageEvent( text="/restart", message_type=MessageType.TEXT, source=source, message_id="m1", ) result = await runner._handle_restart_command(event) assert "Restarting" in result notify_path = tmp_path / ".restart_notify.json" assert notify_path.exists() data = json.loads(notify_path.read_text()) assert data["platform"] == "telegram" assert data["chat_id"] == "42" assert "thread_id" not in data # no thread → omitted @pytest.mark.asyncio async def test_restart_command_uses_service_restart_under_systemd(tmp_path, monkeypatch): """Under systemd (INVOCATION_ID set), /restart uses via_service=True.""" monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) monkeypatch.setenv("INVOCATION_ID", "abc123") runner, _adapter = make_restart_runner() runner.request_restart = MagicMock(return_value=True) source = make_restart_source(chat_id="42") event = MessageEvent( text="/restart", message_type=MessageType.TEXT, source=source, message_id="m1", ) await runner._handle_restart_command(event) runner.request_restart.assert_called_once_with(detached=False, via_service=True) @pytest.mark.asyncio async def test_restart_command_uses_detached_without_systemd(tmp_path, monkeypatch): """Without systemd, /restart uses the detached subprocess approach.""" monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) monkeypatch.delenv("INVOCATION_ID", raising=False) runner, _adapter = make_restart_runner() runner.request_restart = MagicMock(return_value=True) source = make_restart_source(chat_id="42") event = MessageEvent( text="/restart", message_type=MessageType.TEXT, source=source, message_id="m1", ) await runner._handle_restart_command(event) runner.request_restart.assert_called_once_with(detached=True, via_service=False) @pytest.mark.asyncio async def test_restart_command_preserves_thread_id(tmp_path, monkeypatch): """Thread ID is saved when the requester is in a threaded chat.""" monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) runner, _adapter = make_restart_runner() runner.request_restart = MagicMock(return_value=True) source = make_restart_source(chat_id="99") source.thread_id = "topic_7" event = MessageEvent( text="/restart", message_type=MessageType.TEXT, source=source, message_id="m2", ) await runner._handle_restart_command(event) data = json.loads((tmp_path / ".restart_notify.json").read_text()) assert data["thread_id"] == "topic_7" @pytest.mark.asyncio async def test_restart_command_uses_atomic_json_writes_for_marker_files(tmp_path, monkeypatch): monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) calls = [] def _fake_atomic_json_write(path, payload, **kwargs): calls.append((Path(path).name, payload, kwargs)) monkeypatch.setattr(gateway_run, "atomic_json_write", _fake_atomic_json_write) runner, _adapter = make_restart_runner() runner.request_restart = MagicMock(return_value=True) source = make_restart_source(chat_id="42") event = MessageEvent( text="/restart", message_type=MessageType.TEXT, source=source, message_id="m1", ) await runner._handle_restart_command(event) names = [name for name, _payload, _kwargs in calls] assert names == [".restart_notify.json", ".restart_last_processed.json"] assert calls[0][1]["chat_id"] == "42" assert calls[1][1]["platform"] == "telegram" @pytest.mark.asyncio async def test_sethome_updates_running_config_for_same_process_restart(tmp_path, monkeypatch): """/sethome persists to env and updates in-memory config before restart.""" monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) saved = {} def _fake_save_env_value(key, value): saved[key] = value monkeypatch.setattr("hermes_cli.config.save_env_value", _fake_save_env_value) runner, _adapter = make_restart_runner() source = make_restart_source(chat_id="home-42") source.chat_name = "Ops Home" event = MessageEvent( text="/sethome", message_type=MessageType.TEXT, source=source, message_id="m-home", ) result = await runner._handle_set_home_command(event) home = runner.config.get_home_channel(Platform.TELEGRAM) assert "Home channel set" in result assert saved["TELEGRAM_HOME_CHANNEL"] == "home-42" assert home is not None assert home.chat_id == "home-42" assert home.name == "Ops Home" @pytest.mark.asyncio async def test_sethome_preserves_thread_target_for_same_process_restart(tmp_path, monkeypatch): """/sethome from a topic/thread stores the thread-aware home target.""" monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) saved = {} def _fake_save_env_value(key, value): saved[key] = value monkeypatch.setattr("hermes_cli.config.save_env_value", _fake_save_env_value) runner, _adapter = make_restart_runner() source = make_restart_source(chat_id="parent-42", thread_id="topic-7") source.chat_name = "Ops Topic" event = MessageEvent( text="/sethome", message_type=MessageType.TEXT, source=source, message_id="m-home-thread", ) result = await runner._handle_set_home_command(event) home = runner.config.get_home_channel(Platform.TELEGRAM) assert "Home channel set" in result assert saved["TELEGRAM_HOME_CHANNEL"] == "parent-42" assert saved["TELEGRAM_HOME_CHANNEL_THREAD_ID"] == "topic-7" assert home is not None assert home.chat_id == "parent-42" assert home.thread_id == "topic-7" # ── home-channel startup notifications ───────────────────────────────────── @pytest.mark.asyncio async def test_send_home_channel_startup_notification_to_configured_home(tmp_path, monkeypatch): monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) runner, adapter = make_restart_runner() runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( platform=Platform.TELEGRAM, chat_id="home-42", name="Ops Home", ) adapter.send = AsyncMock() delivered = await runner._send_home_channel_startup_notifications() assert delivered == {("telegram", "home-42", None)} adapter.send.assert_called_once_with( "home-42", "♻️ Gateway online — Hermes is back and ready.", ) @pytest.mark.asyncio async def test_send_home_channel_startup_notification_preserves_thread_metadata( tmp_path, monkeypatch ): monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) runner, adapter = make_restart_runner() runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( platform=Platform.TELEGRAM, chat_id="parent-42", name="Ops Topic", thread_id="topic-7", ) adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="home")) delivered = await runner._send_home_channel_startup_notifications() assert delivered == {("telegram", "parent-42", "topic-7")} adapter.send.assert_called_once_with( "parent-42", "♻️ Gateway online — Hermes is back and ready.", metadata={"thread_id": "topic-7"}, ) @pytest.mark.asyncio async def test_send_home_channel_startup_notification_skips_restart_target( tmp_path, monkeypatch ): monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) runner, adapter = make_restart_runner() runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( platform=Platform.TELEGRAM, chat_id="42", name="Ops Home", ) adapter.send = AsyncMock() delivered = await runner._send_home_channel_startup_notifications( skip_targets={("telegram", "42", None)} ) assert delivered == set() adapter.send.assert_not_called() @pytest.mark.asyncio async def test_send_home_channel_startup_notification_does_not_skip_different_thread( tmp_path, monkeypatch ): monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) runner, adapter = make_restart_runner() runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( platform=Platform.TELEGRAM, chat_id="42", name="Ops Home", ) adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="home")) delivered = await runner._send_home_channel_startup_notifications( skip_targets={("telegram", "42", "topic-7")} ) assert delivered == {("telegram", "42", None)} adapter.send.assert_called_once() @pytest.mark.asyncio async def test_send_home_channel_startup_notification_ignores_false_send_result( tmp_path, monkeypatch ): monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) runner, adapter = make_restart_runner() runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( platform=Platform.TELEGRAM, chat_id="home-42", name="Ops Home", ) adapter.send = AsyncMock(return_value=SendResult(success=False, error="network down")) delivered = await runner._send_home_channel_startup_notifications() assert delivered == set() adapter.send.assert_called_once() # ── _send_restart_notification ─────────────────────────────────────────── @pytest.mark.asyncio async def test_send_restart_notification_delivers_and_cleans_up(tmp_path, monkeypatch): """On startup, the notification is sent and the file is removed.""" monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) notify_path = tmp_path / ".restart_notify.json" notify_path.write_text(json.dumps({ "platform": "telegram", "chat_id": "42", })) runner, adapter = make_restart_runner() adapter.send = AsyncMock() delivered_target = await runner._send_restart_notification() assert delivered_target == ("telegram", "42", None) adapter.send.assert_called_once() call_args = adapter.send.call_args assert call_args[0][0] == "42" # chat_id assert "restarted" in call_args[0][1].lower() assert call_args[1].get("metadata") is None # no thread assert not notify_path.exists() @pytest.mark.asyncio async def test_send_restart_notification_with_thread(tmp_path, monkeypatch): """Thread ID is passed as metadata so the message lands in the right topic.""" monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) notify_path = tmp_path / ".restart_notify.json" notify_path.write_text(json.dumps({ "platform": "telegram", "chat_id": "99", "thread_id": "topic_7", })) runner, adapter = make_restart_runner() adapter.send = AsyncMock() delivered_target = await runner._send_restart_notification() assert delivered_target == ("telegram", "99", "topic_7") call_args = adapter.send.call_args assert call_args[1]["metadata"] == {"thread_id": "topic_7"} assert not notify_path.exists() @pytest.mark.asyncio async def test_send_restart_notification_noop_when_no_file(tmp_path, monkeypatch): """Nothing happens if there's no pending restart notification.""" monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) runner, adapter = make_restart_runner() adapter.send = AsyncMock() await runner._send_restart_notification() adapter.send.assert_not_called() @pytest.mark.asyncio async def test_send_restart_notification_skips_when_adapter_missing(tmp_path, monkeypatch): """If the requester's platform isn't connected, clean up without crashing.""" monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) notify_path = tmp_path / ".restart_notify.json" notify_path.write_text(json.dumps({ "platform": "discord", # runner only has telegram adapter "chat_id": "42", })) runner, _adapter = make_restart_runner() await runner._send_restart_notification() # File cleaned up even though we couldn't send assert not notify_path.exists() @pytest.mark.asyncio async def test_send_restart_notification_cleans_up_on_send_failure( tmp_path, monkeypatch ): """If the adapter.send() raises, the file is still cleaned up.""" monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) notify_path = tmp_path / ".restart_notify.json" notify_path.write_text(json.dumps({ "platform": "telegram", "chat_id": "42", })) runner, adapter = make_restart_runner() adapter.send = AsyncMock(side_effect=RuntimeError("network down")) delivered_target = await runner._send_restart_notification() # File cleaned up even though send raised. assert delivered_target is None assert not notify_path.exists() @pytest.mark.asyncio async def test_send_restart_notification_logs_warning_on_sendresult_failure( tmp_path, monkeypatch, caplog ): """Adapter that returns SendResult(success=False) must log a WARNING, not INFO. Regression guard: adapter.send() catches provider errors (e.g. Telegram "Chat not found") and returns SendResult(success=False) rather than raising. The caller previously ignored the return value and always logged "Sent restart notification to ..." at INFO — masking real delivery failures behind a fake success line. """ from gateway.platforms.base import SendResult monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) notify_path = tmp_path / ".restart_notify.json" notify_path.write_text(json.dumps({ "platform": "telegram", "chat_id": "42", })) runner, adapter = make_restart_runner() adapter.send = AsyncMock( return_value=SendResult(success=False, error="Chat not found"), ) with caplog.at_level("DEBUG", logger="gateway.run"): delivered_target = await runner._send_restart_notification() success_lines = [ r for r in caplog.records if r.levelname == "INFO" and "Sent restart notification" in r.getMessage() ] warning_lines = [ r for r in caplog.records if r.levelname == "WARNING" and "was not delivered" in r.getMessage() and "Chat not found" in r.getMessage() ] assert delivered_target is None assert not success_lines, ( "Expected no INFO 'Sent restart notification' line when send failed, " f"got: {[r.getMessage() for r in success_lines]}" ) assert warning_lines, ( "Expected a WARNING line mentioning the failure; " f"got records: {[(r.levelname, r.getMessage()) for r in caplog.records]}" ) # Still cleans up. assert not notify_path.exists() @pytest.mark.asyncio async def test_send_restart_notification_logs_info_on_sendresult_success( tmp_path, monkeypatch, caplog ): """Adapter returning SendResult(success=True) keeps the INFO log line.""" from gateway.platforms.base import SendResult monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) notify_path = tmp_path / ".restart_notify.json" notify_path.write_text(json.dumps({ "platform": "telegram", "chat_id": "42", })) runner, adapter = make_restart_runner() adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="m-1")) with caplog.at_level("DEBUG", logger="gateway.run"): delivered_target = await runner._send_restart_notification() success_lines = [ r for r in caplog.records if r.levelname == "INFO" and "Sent restart notification" in r.getMessage() ] assert delivered_target == ("telegram", "42", None) assert success_lines, ( "Expected INFO 'Sent restart notification' when send succeeded; " f"got records: {[(r.levelname, r.getMessage()) for r in caplog.records]}" ) assert not notify_path.exists()