#691 telegram tests CI-safe: fully-qualified imports + hermetic network-block fixture. 16 test files bare 'from apps.handlers' -> 'aipass.skills.lib.telegram.apps.handlers' (+ patch targets) — bare 'apps' collided with other branches' apps at full-repo collection -> ~16 collection errors -> CI red. Verify caught ~11 tests hitting live api.telegram.org (base_bot->set_bot_commands->urlopen, never ran in CI before); added session autouse _block_network conftest fixture patching urlopen on 4 telegram modules (bare+fq, guarded) so live calls fail loud not hang. Test-infra only, product byte-unchanged, 0 assertion changes. Full-repo collect 0 errors (11028); telegram 663 pass/0 fail/0 hang. devpulse independently re-verified.

This commit is contained in:
AIOSAI
2026-07-11 09:06:34 -07:00
parent c244b7bf3f
commit deffa0c912
19 changed files with 439 additions and 386 deletions
+17
View File
@@ -34,6 +34,23 @@ PyPI version — not the changelog header.
### Fixed ### Fixed
- **Telegram skill tests made CI-safe — full-repo collection + hermeticity (issue #691).**
The 16 test files under `skills/lib/telegram/tests/` imported handlers via bare
`from apps.handlers…`, which collided with other branches' `apps` packages during
full-repo CI collection (~16 ImportError collection errors → CI red on Linux +
Windows). Converted to fully-qualified `aipass.skills.lib.telegram.apps.handlers.*`
imports (and matching `mock.patch` targets). Verifying that fix surfaced a second
problem the imports had exposed: ~11 tests reached the live Telegram API
(`base_bot.run → _set_command_menu → set_bot_commands → urlopen`) — they had never
run in CI before because they failed at collection. Added a session-scoped autouse
`_block_network` conftest fixture that patches `urlopen` on the four network-using
telegram modules (both bare and fully-qualified import paths, each guarded) so any
test attempting a live HTTP call fails loud instead of hanging. Test-infra only —
product code byte-unchanged, no telegram behavior change. Full-repo collection now
0 errors (11,028 tests); telegram suite 663 passed / 0 failed / 0 hangs, fully
hermetic. Coverage intact (zero assertion changes — purely import/patch-target
rewiring).
- **`aipass install` from a throwaway path can no longer hijack the machine-wide - **`aipass install` from a throwaway path can no longer hijack the machine-wide
`AIPASS_HOME` (issue #688).** A probe install run from a `/tmp` scratchpad had `AIPASS_HOME` (issue #688).** A probe install run from a `/tmp` scratchpad had
rewritten `~/.claude/settings.json` `env.AIPASS_HOME`, silently pointing every rewritten `~/.claude/settings.json` `env.AIPASS_HOME`, silently pointing every
@@ -9,10 +9,10 @@
""" """
Telegram skill test configuration. Telegram skill test configuration.
Sets up sys.path so that both aipass.* (installed package) and the local Sets up sys.path so that aipass.* (installed package) is importable from tests
apps.handlers.* namespace are importable from tests without a full pip install. without a full pip install. Also stubs the optional telethon dependency and
Also stubs the optional telethon dependency and redirects Prax logger output redirects Prax logger output to a temp dir so test runs don't pollute production
to a temp dir so test runs don't pollute production log files. log files.
""" """
import os import os
@@ -28,9 +28,10 @@ if "AIPASS_TEST_LOG_DIR" not in os.environ:
import pytest import pytest
# sys.path setup is intentional test infrastructure — both entries are needed: # sys.path setup:
# _src_root → resolves aipass.* installed-package imports # _src_root → resolves aipass.* installed-package imports (test imports)
# _skill_root → resolves the local apps.handlers.* namespace used by all tests # _skill_root → resolves bare 'apps.handlers' lazy imports inside handler.py
# (product code, exercised at test runtime — not at collection)
_src_root = Path(__file__).resolve().parents[5] _src_root = Path(__file__).resolve().parents[5]
if str(_src_root) not in sys.path: if str(_src_root) not in sys.path:
sys.path.insert(0, str(_src_root)) sys.path.insert(0, str(_src_root))
@@ -88,6 +89,49 @@ def _redirect_prax_logs(tmp_path_factory):
direct_mod._direct_loggers.clear() direct_mod._direct_loggers.clear()
class _NetworkBlockedError(Exception):
"""Raised when a test attempts a real network call."""
def __init__(self):
super().__init__("NETWORK BLOCKED: test attempted a live HTTP call. Mock urlopen or the calling function.")
def _blocked_urlopen(*args, **kwargs):
raise _NetworkBlockedError()
_URLOPEN_TARGETS = [
"aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen",
"aipass.skills.lib.telegram.apps.handlers.bot_factory.urlopen",
"aipass.skills.lib.telegram.apps.handlers.notifier.urlopen",
"aipass.skills.lib.telegram.apps.handlers.log_streamer.urlopen",
"apps.handlers.base_bot.urlopen",
"apps.handlers.bot_factory.urlopen",
"apps.handlers.notifier.urlopen",
"apps.handlers.log_streamer.urlopen",
]
@pytest.fixture(autouse=True, scope="session")
def _block_network():
"""Block all outbound HTTP in tests. Any test hitting the real network fails loud."""
from unittest.mock import patch
patches = []
for target in _URLOPEN_TARGETS:
try:
p = patch(target, side_effect=_blocked_urlopen)
p.start()
patches.append(p)
except (ModuleNotFoundError, AttributeError):
pass
yield
for p in patches:
p.stop()
@pytest.fixture @pytest.fixture
def temp_test_dir() -> Generator[Path, None, None]: def temp_test_dir() -> Generator[Path, None, None]:
"""Creates temporary directory for testing, cleans up after.""" """Creates temporary directory for testing, cleans up after."""
@@ -23,15 +23,15 @@ from unittest.mock import patch, MagicMock
import pytest import pytest
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
@pytest.fixture @pytest.fixture
def _patch_base_bot_deps(tmp_path): def _patch_base_bot_deps(tmp_path):
patches = [ patches = [
patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path),
patch("apps.handlers.base_bot.signal.signal"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.signal.signal"),
patch("apps.handlers.base_bot.atexit.register"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.atexit.register"),
] ]
for p in patches: for p in patches:
p.start() p.start()
@@ -43,7 +43,7 @@ def _patch_base_bot_deps(tmp_path):
def _make_bot(tmp_path, _patch_base_bot_deps, attach_only=False, shared_session=None): def _make_bot(tmp_path, _patch_base_bot_deps, attach_only=False, shared_session=None):
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir(exist_ok=True) workdir.mkdir(exist_ok=True)
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BaseBot( bot = BaseBot(
bot_id="mirror_test", bot_id="mirror_test",
bot_token="123:FAKETOKEN", bot_token="123:FAKETOKEN",
@@ -253,7 +253,7 @@ class TestAttachOnlyLock:
patch.object(bot, "clean_stale_pending"), patch.object(bot, "clean_stale_pending"),
patch.object(bot, "_load_offset", return_value=0), patch.object(bot, "_load_offset", return_value=0),
patch.object(bot, "poll_updates", side_effect=KeyboardInterrupt), patch.object(bot, "poll_updates", side_effect=KeyboardInterrupt),
patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path),
): ):
bot.run() bot.run()
@@ -273,7 +273,7 @@ class TestAttachOnlyLock:
patch.object(bot, "clean_stale_pending"), patch.object(bot, "clean_stale_pending"),
patch.object(bot, "_load_offset", return_value=0), patch.object(bot, "_load_offset", return_value=0),
patch.object(bot, "poll_updates", side_effect=KeyboardInterrupt), patch.object(bot, "poll_updates", side_effect=KeyboardInterrupt),
patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path),
): ):
bot.run() bot.run()
@@ -8,7 +8,7 @@ No external dependencies beyond pytest.
import json import json
import pytest import pytest
from apps.handlers import bot_registry # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers import bot_registry
# ============================================= # =============================================
@@ -17,7 +17,7 @@ import asyncio
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock, AsyncMock from unittest.mock import patch, MagicMock, AsyncMock
from apps.handlers.botfather_client import ( from aipass.skills.lib.telegram.apps.handlers.botfather_client import (
_load_telethon_config, _load_telethon_config,
check_telethon_setup, check_telethon_setup,
_format_display_name, _format_display_name,
@@ -36,7 +36,7 @@ from apps.handlers.botfather_client import (
class TestLoadTelethonConfig: class TestLoadTelethonConfig:
"""Test _load_telethon_config: secret loading, JSON parsing, validation.""" """Test _load_telethon_config: secret loading, JSON parsing, validation."""
@patch("apps.handlers.botfather_client._get_secret") @patch("aipass.skills.lib.telegram.apps.handlers.botfather_client._get_secret")
def test_returns_config_when_valid(self, mock_get_secret): def test_returns_config_when_valid(self, mock_get_secret):
"""Returns config dict when secret store has valid api_id and api_hash.""" """Returns config dict when secret store has valid api_id and api_hash."""
mock_get_secret.return_value = {"api_id": 12345, "api_hash": "abc123def"} mock_get_secret.return_value = {"api_id": 12345, "api_hash": "abc123def"}
@@ -46,7 +46,7 @@ class TestLoadTelethonConfig:
assert result["api_hash"] == "abc123def" assert result["api_hash"] == "abc123def"
mock_get_secret.assert_called_once_with("telethon_config") mock_get_secret.assert_called_once_with("telethon_config")
@patch("apps.handlers.botfather_client._get_secret") @patch("aipass.skills.lib.telegram.apps.handlers.botfather_client._get_secret")
def test_raises_when_secret_missing(self, mock_get_secret): def test_raises_when_secret_missing(self, mock_get_secret):
"""Raises RuntimeError when the secret doesn't exist.""" """Raises RuntimeError when the secret doesn't exist."""
mock_get_secret.return_value = None mock_get_secret.return_value = None
@@ -55,7 +55,7 @@ class TestLoadTelethonConfig:
with pytest.raises(RuntimeError, match="not found in secrets store"): with pytest.raises(RuntimeError, match="not found in secrets store"):
_load_telethon_config() _load_telethon_config()
@patch("apps.handlers.botfather_client._get_secret") @patch("aipass.skills.lib.telegram.apps.handlers.botfather_client._get_secret")
def test_raises_when_api_id_missing(self, mock_get_secret): def test_raises_when_api_id_missing(self, mock_get_secret):
"""Raises RuntimeError when api_id is missing from config.""" """Raises RuntimeError when api_id is missing from config."""
mock_get_secret.return_value = {"api_hash": "abc123def"} mock_get_secret.return_value = {"api_hash": "abc123def"}
@@ -64,7 +64,7 @@ class TestLoadTelethonConfig:
with pytest.raises(RuntimeError, match="missing api_id or api_hash"): with pytest.raises(RuntimeError, match="missing api_id or api_hash"):
_load_telethon_config() _load_telethon_config()
@patch("apps.handlers.botfather_client._get_secret") @patch("aipass.skills.lib.telegram.apps.handlers.botfather_client._get_secret")
def test_coerces_api_id_from_string(self, mock_get_secret): def test_coerces_api_id_from_string(self, mock_get_secret):
"""Coerces api_id to int when provided as a string.""" """Coerces api_id to int when provided as a string."""
mock_get_secret.return_value = {"api_id": "99999", "api_hash": "xyz789"} mock_get_secret.return_value = {"api_id": "99999", "api_hash": "xyz789"}
@@ -82,16 +82,16 @@ class TestLoadTelethonConfig:
class TestCheckTelethonSetup: class TestCheckTelethonSetup:
"""Test check_telethon_setup: checks library, config, session file.""" """Test check_telethon_setup: checks library, config, session file."""
@patch("apps.handlers.botfather_client._load_telethon_config") @patch("aipass.skills.lib.telegram.apps.handlers.botfather_client._load_telethon_config")
def test_returns_ready_when_all_in_place(self, mock_load_config, tmp_path, monkeypatch): def test_returns_ready_when_all_in_place(self, mock_load_config, tmp_path, monkeypatch):
"""Returns (True, 'ready') when Telethon is available, config valid, session exists.""" """Returns (True, 'ready') when Telethon is available, config valid, session exists."""
monkeypatch.setattr("apps.handlers.botfather_client.TELETHON_AVAILABLE", True) monkeypatch.setattr("aipass.skills.lib.telegram.apps.handlers.botfather_client.TELETHON_AVAILABLE", True)
mock_load_config.return_value = {"api_id": 12345, "api_hash": "abc123"} mock_load_config.return_value = {"api_id": 12345, "api_hash": "abc123"}
# Create session file at the new path # Create session file at the new path
session_path = tmp_path / ".telethon" session_path = tmp_path / ".telethon"
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.botfather_client.SESSION_PATH", "aipass.skills.lib.telegram.apps.handlers.botfather_client.SESSION_PATH",
session_path, session_path,
) )
session_file = Path(str(session_path) + ".session") session_file = Path(str(session_path) + ".session")
@@ -103,15 +103,15 @@ class TestCheckTelethonSetup:
def test_returns_false_when_telethon_not_available(self, monkeypatch): def test_returns_false_when_telethon_not_available(self, monkeypatch):
"""Returns (False, ...) when TELETHON_AVAILABLE is False.""" """Returns (False, ...) when TELETHON_AVAILABLE is False."""
monkeypatch.setattr("apps.handlers.botfather_client.TELETHON_AVAILABLE", False) monkeypatch.setattr("aipass.skills.lib.telegram.apps.handlers.botfather_client.TELETHON_AVAILABLE", False)
ready, reason = check_telethon_setup() ready, reason = check_telethon_setup()
assert ready is False assert ready is False
assert "not installed" in reason.lower() or "telethon" in reason.lower() assert "not installed" in reason.lower() or "telethon" in reason.lower()
@patch("apps.handlers.botfather_client._load_telethon_config") @patch("aipass.skills.lib.telegram.apps.handlers.botfather_client._load_telethon_config")
def test_returns_false_when_config_not_in_secrets(self, mock_load_config, monkeypatch): def test_returns_false_when_config_not_in_secrets(self, mock_load_config, monkeypatch):
"""Returns (False, ...) when secret store has no telethon config.""" """Returns (False, ...) when secret store has no telethon config."""
monkeypatch.setattr("apps.handlers.botfather_client.TELETHON_AVAILABLE", True) monkeypatch.setattr("aipass.skills.lib.telegram.apps.handlers.botfather_client.TELETHON_AVAILABLE", True)
mock_load_config.side_effect = RuntimeError( mock_load_config.side_effect = RuntimeError(
"Telethon config not found in secrets store (telegram/telethon_config)" "Telethon config not found in secrets store (telegram/telethon_config)"
) )
@@ -119,24 +119,24 @@ class TestCheckTelethonSetup:
assert ready is False assert ready is False
assert "not found" in reason.lower() assert "not found" in reason.lower()
@patch("apps.handlers.botfather_client._load_telethon_config") @patch("aipass.skills.lib.telegram.apps.handlers.botfather_client._load_telethon_config")
def test_returns_false_when_config_invalid(self, mock_load_config, monkeypatch): def test_returns_false_when_config_invalid(self, mock_load_config, monkeypatch):
"""Returns (False, ...) when config exists but is invalid (missing fields).""" """Returns (False, ...) when config exists but is invalid (missing fields)."""
monkeypatch.setattr("apps.handlers.botfather_client.TELETHON_AVAILABLE", True) monkeypatch.setattr("aipass.skills.lib.telegram.apps.handlers.botfather_client.TELETHON_AVAILABLE", True)
mock_load_config.side_effect = RuntimeError("Telethon config incomplete — missing api_id or api_hash") mock_load_config.side_effect = RuntimeError("Telethon config incomplete — missing api_id or api_hash")
ready, reason = check_telethon_setup() ready, reason = check_telethon_setup()
assert ready is False assert ready is False
assert "missing api_id or api_hash" in reason assert "missing api_id or api_hash" in reason
@patch("apps.handlers.botfather_client._load_telethon_config") @patch("aipass.skills.lib.telegram.apps.handlers.botfather_client._load_telethon_config")
def test_returns_false_when_session_file_missing(self, mock_load_config, tmp_path, monkeypatch): def test_returns_false_when_session_file_missing(self, mock_load_config, tmp_path, monkeypatch):
"""Returns (False, ...) when session file doesn't exist.""" """Returns (False, ...) when session file doesn't exist."""
monkeypatch.setattr("apps.handlers.botfather_client.TELETHON_AVAILABLE", True) monkeypatch.setattr("aipass.skills.lib.telegram.apps.handlers.botfather_client.TELETHON_AVAILABLE", True)
mock_load_config.return_value = {"api_id": 12345, "api_hash": "abc123"} mock_load_config.return_value = {"api_id": 12345, "api_hash": "abc123"}
session_path = tmp_path / ".telethon" session_path = tmp_path / ".telethon"
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.botfather_client.SESSION_PATH", "aipass.skills.lib.telegram.apps.handlers.botfather_client.SESSION_PATH",
session_path, session_path,
) )
# Do NOT create the session file # Do NOT create the session file
@@ -202,7 +202,7 @@ class TestBotFatherClientConnect:
mock_client_instance.get_me.return_value = MagicMock(first_name="TestUser", id=123) mock_client_instance.get_me.return_value = MagicMock(first_name="TestUser", id=123)
with patch( with patch(
"apps.handlers.botfather_client._telethon_check", "aipass.skills.lib.telegram.apps.handlers.botfather_client._telethon_check",
create=True, create=True,
): ):
with patch( with patch(
@@ -299,7 +299,7 @@ class TestBotFatherClientSendAndWait:
# Use a real time base so asyncio loop isn't disrupted. # Use a real time base so asyncio loop isn't disrupted.
# MESSAGE_TIMEOUT is 30s; the mock response arrives immediately, # MESSAGE_TIMEOUT is 30s; the mock response arrives immediately,
# so the while-loop condition is satisfied on the first iteration. # so the while-loop condition is satisfied on the first iteration.
with patch("apps.handlers.botfather_client.asyncio.sleep", new_callable=AsyncMock): with patch("aipass.skills.lib.telegram.apps.handlers.botfather_client.asyncio.sleep", new_callable=AsyncMock):
result = asyncio.run(client._send_and_wait(entity, "/newbot")) result = asyncio.run(client._send_and_wait(entity, "/newbot"))
assert result == "Please choose a name for your bot." assert result == "Please choose a name for your bot."
@@ -317,8 +317,8 @@ class TestBotFatherClientSendAndWait:
mock_telethon.get_messages.return_value = [mock_msg] mock_telethon.get_messages.return_value = [mock_msg]
# Shrink the timeout to 0 so the while-loop exits immediately # Shrink the timeout to 0 so the while-loop exits immediately
with patch("apps.handlers.botfather_client.MESSAGE_TIMEOUT", 0): with patch("aipass.skills.lib.telegram.apps.handlers.botfather_client.MESSAGE_TIMEOUT", 0):
with patch("apps.handlers.botfather_client.asyncio.sleep", new_callable=AsyncMock): with patch("aipass.skills.lib.telegram.apps.handlers.botfather_client.asyncio.sleep", new_callable=AsyncMock):
result = asyncio.run(client._send_and_wait(entity, "/newbot")) result = asyncio.run(client._send_and_wait(entity, "/newbot"))
assert result is None assert result is None
@@ -344,7 +344,7 @@ class TestBotFatherClientSendAndWait:
mock_msg.text = "Response after flood wait" mock_msg.text = "Response after flood wait"
mock_telethon.get_messages.return_value = [mock_msg] mock_telethon.get_messages.return_value = [mock_msg]
with patch("apps.handlers.botfather_client.asyncio.sleep", new_callable=AsyncMock): with patch("aipass.skills.lib.telegram.apps.handlers.botfather_client.asyncio.sleep", new_callable=AsyncMock):
with patch("telethon.errors.FloodWaitError", MockFloodWaitError, create=True): with patch("telethon.errors.FloodWaitError", MockFloodWaitError, create=True):
with patch("telethon.errors.RPCError", MockRPCError, create=True): with patch("telethon.errors.RPCError", MockRPCError, create=True):
result = asyncio.run(client._send_and_wait(entity, "/newbot")) result = asyncio.run(client._send_and_wait(entity, "/newbot"))
@@ -601,7 +601,7 @@ class TestCreateBotViaBotfather:
def test_raises_when_setup_not_ready(self, monkeypatch): def test_raises_when_setup_not_ready(self, monkeypatch):
"""Raises RuntimeError when check_telethon_setup says not ready.""" """Raises RuntimeError when check_telethon_setup says not ready."""
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.botfather_client.check_telethon_setup", "aipass.skills.lib.telegram.apps.handlers.botfather_client.check_telethon_setup",
lambda: (False, "Telethon not installed"), lambda: (False, "Telethon not installed"),
) )
import pytest import pytest
@@ -612,7 +612,7 @@ class TestCreateBotViaBotfather:
def test_raises_when_config_load_fails(self, monkeypatch): def test_raises_when_config_load_fails(self, monkeypatch):
"""Raises RuntimeError when _load_telethon_config raises.""" """Raises RuntimeError when _load_telethon_config raises."""
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.botfather_client.check_telethon_setup", "aipass.skills.lib.telegram.apps.handlers.botfather_client.check_telethon_setup",
lambda: (True, "ready"), lambda: (True, "ready"),
) )
@@ -620,7 +620,7 @@ class TestCreateBotViaBotfather:
raise RuntimeError("Telethon config not found in secrets store (telegram/telethon_config)") raise RuntimeError("Telethon config not found in secrets store (telegram/telethon_config)")
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.botfather_client._load_telethon_config", "aipass.skills.lib.telegram.apps.handlers.botfather_client._load_telethon_config",
_raise, _raise,
) )
import pytest import pytest
@@ -631,11 +631,11 @@ class TestCreateBotViaBotfather:
def test_returns_result_on_success(self, monkeypatch): def test_returns_result_on_success(self, monkeypatch):
"""Returns result dict on successful flow.""" """Returns result dict on successful flow."""
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.botfather_client.check_telethon_setup", "aipass.skills.lib.telegram.apps.handlers.botfather_client.check_telethon_setup",
lambda: (True, "ready"), lambda: (True, "ready"),
) )
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.botfather_client._load_telethon_config", "aipass.skills.lib.telegram.apps.handlers.botfather_client._load_telethon_config",
lambda: {"api_id": 12345, "api_hash": "abc123"}, lambda: {"api_id": 12345, "api_hash": "abc123"},
) )
@@ -661,7 +661,7 @@ class TestCreateBotViaBotfather:
mock_client.disconnect = mock_disconnect mock_client.disconnect = mock_disconnect
with patch( with patch(
"apps.handlers.botfather_client.BotFatherClient", "aipass.skills.lib.telegram.apps.handlers.botfather_client.BotFatherClient",
return_value=mock_client, return_value=mock_client,
): ):
result = create_bot_via_botfather("dev_central") result = create_bot_via_botfather("dev_central")
@@ -673,11 +673,11 @@ class TestCreateBotViaBotfather:
def test_handles_connection_failure(self, monkeypatch): def test_handles_connection_failure(self, monkeypatch):
"""Returns None when connection fails.""" """Returns None when connection fails."""
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.botfather_client.check_telethon_setup", "aipass.skills.lib.telegram.apps.handlers.botfather_client.check_telethon_setup",
lambda: (True, "ready"), lambda: (True, "ready"),
) )
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.botfather_client._load_telethon_config", "aipass.skills.lib.telegram.apps.handlers.botfather_client._load_telethon_config",
lambda: {"api_id": 12345, "api_hash": "abc123"}, lambda: {"api_id": 12345, "api_hash": "abc123"},
) )
@@ -693,7 +693,7 @@ class TestCreateBotViaBotfather:
mock_client.disconnect = mock_disconnect mock_client.disconnect = mock_disconnect
with patch( with patch(
"apps.handlers.botfather_client.BotFatherClient", "aipass.skills.lib.telegram.apps.handlers.botfather_client.BotFatherClient",
return_value=mock_client, return_value=mock_client,
): ):
result = create_bot_via_botfather("dev_central") result = create_bot_via_botfather("dev_central")
@@ -703,11 +703,11 @@ class TestCreateBotViaBotfather:
def test_handles_botfather_failure(self, monkeypatch): def test_handles_botfather_failure(self, monkeypatch):
"""Returns None when BotFather automation fails.""" """Returns None when BotFather automation fails."""
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.botfather_client.check_telethon_setup", "aipass.skills.lib.telegram.apps.handlers.botfather_client.check_telethon_setup",
lambda: (True, "ready"), lambda: (True, "ready"),
) )
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.botfather_client._load_telethon_config", "aipass.skills.lib.telegram.apps.handlers.botfather_client._load_telethon_config",
lambda: {"api_id": 12345, "api_hash": "abc123"}, lambda: {"api_id": 12345, "api_hash": "abc123"},
) )
@@ -727,7 +727,7 @@ class TestCreateBotViaBotfather:
mock_client.disconnect = mock_disconnect mock_client.disconnect = mock_disconnect
with patch( with patch(
"apps.handlers.botfather_client.BotFatherClient", "aipass.skills.lib.telegram.apps.handlers.botfather_client.BotFatherClient",
return_value=mock_client, return_value=mock_client,
): ):
result = create_bot_via_botfather("dev_central") result = create_bot_via_botfather("dev_central")
@@ -737,11 +737,11 @@ class TestCreateBotViaBotfather:
def test_handles_existing_event_loop(self, monkeypatch): def test_handles_existing_event_loop(self, monkeypatch):
"""Handles the edge case where an event loop is already running.""" """Handles the edge case where an event loop is already running."""
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.botfather_client.check_telethon_setup", "aipass.skills.lib.telegram.apps.handlers.botfather_client.check_telethon_setup",
lambda: (True, "ready"), lambda: (True, "ready"),
) )
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.botfather_client._load_telethon_config", "aipass.skills.lib.telegram.apps.handlers.botfather_client._load_telethon_config",
lambda: {"api_id": 12345, "api_hash": "abc123"}, lambda: {"api_id": 12345, "api_hash": "abc123"},
) )
@@ -767,7 +767,7 @@ class TestCreateBotViaBotfather:
mock_client.disconnect = mock_disconnect mock_client.disconnect = mock_disconnect
with patch( with patch(
"apps.handlers.botfather_client.BotFatherClient", "aipass.skills.lib.telegram.apps.handlers.botfather_client.BotFatherClient",
return_value=mock_client, return_value=mock_client,
): ):
# Simulate an already-running event loop by patching get_running_loop # Simulate an already-running event loop by patching get_running_loop
@@ -776,7 +776,7 @@ class TestCreateBotViaBotfather:
mock_loop.is_running.return_value = True mock_loop.is_running.return_value = True
with patch( with patch(
"apps.handlers.botfather_client.asyncio.get_running_loop", "aipass.skills.lib.telegram.apps.handlers.botfather_client.asyncio.get_running_loop",
return_value=mock_loop, return_value=mock_loop,
): ):
result = create_bot_via_botfather("flow") result = create_bot_via_botfather("flow")
@@ -1,16 +1,8 @@
"""Tests for handler.py action routing.""" """Tests for handler.py action routing."""
import sys
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from aipass.skills.lib.telegram.handler import run, _ok, _err, _ACTIONS, _DISPATCH
# Ensure skill root is on path so handler can import apps.handlers.*
_skill_root = Path(__file__).resolve().parents[1]
if str(_skill_root) not in sys.path:
sys.path.insert(0, str(_skill_root))
from handler import run, _ok, _err, _ACTIONS, _DISPATCH
class TestHelpers: class TestHelpers:
@@ -25,15 +25,15 @@ import time
import pytest import pytest
from unittest.mock import patch from unittest.mock import patch
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
@pytest.fixture @pytest.fixture
def _patch_base_bot_deps(tmp_path): def _patch_base_bot_deps(tmp_path):
patches = [ patches = [
patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path),
patch("apps.handlers.base_bot.signal.signal"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.signal.signal"),
patch("apps.handlers.base_bot.atexit.register"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.atexit.register"),
] ]
for p in patches: for p in patches:
p.start() p.start()
@@ -44,7 +44,7 @@ def _patch_base_bot_deps(tmp_path):
def _make_bot(tmp_path, _patch_base_bot_deps, pending_dir=None): def _make_bot(tmp_path, _patch_base_bot_deps, pending_dir=None):
pdir = pending_dir or tmp_path pdir = pending_dir or tmp_path
with patch("apps.handlers.base_bot.PENDING_DIR", pdir): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", pdir):
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir(exist_ok=True) workdir.mkdir(exist_ok=True)
bot = BaseBot( bot = BaseBot(
@@ -121,7 +121,7 @@ class TestHeartbeatStopsOnDelivered:
with ( with (
patch.object(bot, "edit_message", side_effect=fake_edit), patch.object(bot, "edit_message", side_effect=fake_edit),
patch.object(bot, "_tmux_session_exists", return_value=True), patch.object(bot, "_tmux_session_exists", return_value=True),
patch("apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1),
): ):
bot._start_heartbeat(42, 999) bot._start_heartbeat(42, 999)
time.sleep(0.5) time.sleep(0.5)
@@ -144,7 +144,7 @@ class TestHeartbeatStopsOnDelivered:
with ( with (
patch.object(bot, "edit_message", side_effect=fake_edit), patch.object(bot, "edit_message", side_effect=fake_edit),
patch.object(bot, "_tmux_session_exists", return_value=True), patch.object(bot, "_tmux_session_exists", return_value=True),
patch("apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1),
): ):
bot._start_heartbeat(42, 999) bot._start_heartbeat(42, 999)
time.sleep(0.5) time.sleep(0.5)
@@ -161,7 +161,7 @@ class TestHeartbeatStopsOnDelivered:
with ( with (
patch.object(bot, "edit_message") as mock_edit, patch.object(bot, "edit_message") as mock_edit,
patch.object(bot, "_tmux_session_exists", return_value=True), patch.object(bot, "_tmux_session_exists", return_value=True),
patch("apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1),
): ):
bot._start_heartbeat(42, 999) bot._start_heartbeat(42, 999)
time.sleep(0.4) time.sleep(0.4)
@@ -236,7 +236,7 @@ class TestReplyNotClobbered:
with ( with (
patch.object(bot, "edit_message") as mock_edit, patch.object(bot, "edit_message") as mock_edit,
patch.object(bot, "_tmux_session_exists", return_value=True), patch.object(bot, "_tmux_session_exists", return_value=True),
patch("apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1),
): ):
bot._start_heartbeat(42, 999) bot._start_heartbeat(42, 999)
time.sleep(0.4) time.sleep(0.4)
@@ -18,7 +18,7 @@ import threading
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from apps.handlers.log_streamer import ( # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.log_streamer import (
LogStreamer, LogStreamer,
TELEGRAM_MAX_LENGTH, TELEGRAM_MAX_LENGTH,
) )
@@ -31,7 +31,7 @@ from apps.handlers.log_streamer import ( # type: ignore[import-not-found]
def _make_streamer(logs_dir, tmp_path, branch_name: str = "api") -> LogStreamer: def _make_streamer(logs_dir, tmp_path, branch_name: str = "api") -> LogStreamer:
"""Build a LogStreamer with SYSTEM_LOGS_DIR redirected to tmp_path.""" """Build a LogStreamer with SYSTEM_LOGS_DIR redirected to tmp_path."""
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
s = LogStreamer( s = LogStreamer(
bot_token="123:FAKETOKEN", bot_token="123:FAKETOKEN",
chat_id=999888, chat_id=999888,
@@ -149,7 +149,7 @@ class TestPositionTracking:
with open(log_file, "a", encoding="utf-8") as f: with open(log_file, "a", encoding="utf-8") as f:
f.write("new_line_3\nnew_line_4\n") f.write("new_line_3\nnew_line_4\n")
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
new_lines = streamer_with_files._read_new_lines() new_lines = streamer_with_files._read_new_lines()
assert "new_line_3" in new_lines assert "new_line_3" in new_lines
@@ -157,7 +157,7 @@ class TestPositionTracking:
def test_no_new_content_returns_empty(self, streamer_with_files, logs_dir): def test_no_new_content_returns_empty(self, streamer_with_files, logs_dir):
"""If nothing was appended, should return empty list.""" """If nothing was appended, should return empty list."""
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
new_lines = streamer_with_files._read_new_lines() new_lines = streamer_with_files._read_new_lines()
assert new_lines == [] assert new_lines == []
@@ -167,7 +167,7 @@ class TestPositionTracking:
# Simulate rotation: overwrite with smaller content # Simulate rotation: overwrite with smaller content
log_file.write_text("rotated\n", encoding="utf-8") log_file.write_text("rotated\n", encoding="utf-8")
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
new_lines = streamer_with_files._read_new_lines() new_lines = streamer_with_files._read_new_lines()
assert "rotated" in new_lines assert "rotated" in new_lines
@@ -177,7 +177,7 @@ class TestPositionTracking:
new_file = logs_dir / "api_new_module.log" new_file = logs_dir / "api_new_module.log"
new_file.write_text("discovered_line\n", encoding="utf-8") new_file.write_text("discovered_line\n", encoding="utf-8")
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
new_lines = streamer_with_files._read_new_lines() new_lines = streamer_with_files._read_new_lines()
assert "discovered_line" in new_lines assert "discovered_line" in new_lines
@@ -189,7 +189,7 @@ class TestPositionTracking:
with open(non_matching, "a", encoding="utf-8") as f: with open(non_matching, "a", encoding="utf-8") as f:
f.write("should_be_ignored\n") f.write("should_be_ignored\n")
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
new_lines = streamer_with_files._read_new_lines() new_lines = streamer_with_files._read_new_lines()
assert "should_be_ignored" not in new_lines assert "should_be_ignored" not in new_lines
@@ -199,7 +199,7 @@ class TestPositionTracking:
log_file = logs_dir / "api_main.log" log_file = logs_dir / "api_main.log"
log_file.unlink() log_file.unlink()
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
# Should not raise # Should not raise
new_lines = streamer_with_files._read_new_lines() new_lines = streamer_with_files._read_new_lines()
@@ -215,7 +215,7 @@ class TestPositionTracking:
with open(log_file, "a", encoding="utf-8") as f: with open(log_file, "a", encoding="utf-8") as f:
f.write("extra\n") f.write("extra\n")
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
streamer_with_files._read_new_lines() streamer_with_files._read_new_lines()
assert streamer_with_files.log_positions[file_path] > initial_pos assert streamer_with_files.log_positions[file_path] > initial_pos
@@ -223,7 +223,7 @@ class TestPositionTracking:
def test_system_logs_dir_missing_returns_empty(self, streamer, tmp_path): def test_system_logs_dir_missing_returns_empty(self, streamer, tmp_path):
"""If SYSTEM_LOGS_DIR does not exist, _get_log_files returns empty.""" """If SYSTEM_LOGS_DIR does not exist, _get_log_files returns empty."""
missing_dir = tmp_path / "does_not_exist" missing_dir = tmp_path / "does_not_exist"
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", missing_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", missing_dir):
new_lines = streamer._read_new_lines() new_lines = streamer._read_new_lines()
assert new_lines == [] assert new_lines == []
@@ -248,7 +248,7 @@ class TestSendMessage:
"""Payload should include chat_id, text, and disable_notification.""" """Payload should include chat_id, text, and disable_notification."""
mock_resp = self._make_mock_response({"ok": True}) mock_resp = self._make_mock_response({"ok": True})
with patch("apps.handlers.log_streamer.urlopen", return_value=mock_resp) as mock_urlopen: with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.urlopen", return_value=mock_resp) as mock_urlopen:
streamer._send_message("hello world") streamer._send_message("hello world")
# Verify the request was made # Verify the request was made
@@ -264,7 +264,7 @@ class TestSendMessage:
"""Should return True when Telegram responds with ok=True.""" """Should return True when Telegram responds with ok=True."""
mock_resp = self._make_mock_response({"ok": True}) mock_resp = self._make_mock_response({"ok": True})
with patch("apps.handlers.log_streamer.urlopen", return_value=mock_resp): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.urlopen", return_value=mock_resp):
result = streamer._send_message("test") result = streamer._send_message("test")
assert result is True assert result is True
@@ -273,14 +273,14 @@ class TestSendMessage:
"""Should return False and not crash on URLError.""" """Should return False and not crash on URLError."""
from urllib.error import URLError from urllib.error import URLError
with patch("apps.handlers.log_streamer.urlopen", side_effect=URLError("fail")): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.urlopen", side_effect=URLError("fail")):
result = streamer._send_message("test") result = streamer._send_message("test")
assert result is False assert result is False
def test_returns_false_on_generic_exception(self, streamer): def test_returns_false_on_generic_exception(self, streamer):
"""Should return False on any unexpected exception.""" """Should return False on any unexpected exception."""
with patch("apps.handlers.log_streamer.urlopen", side_effect=RuntimeError("boom")): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.urlopen", side_effect=RuntimeError("boom")):
result = streamer._send_message("test") result = streamer._send_message("test")
assert result is False assert result is False
@@ -290,8 +290,8 @@ class TestSendMessage:
from urllib.error import URLError from urllib.error import URLError
with ( with (
patch("apps.handlers.log_streamer.urlopen", side_effect=URLError("network")), patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.urlopen", side_effect=URLError("network")),
patch("apps.handlers.log_streamer.logger") as mock_logger, patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.logger") as mock_logger,
): ):
streamer._send_message("test") streamer._send_message("test")
@@ -302,7 +302,7 @@ class TestSendMessage:
"""Should return False when Telegram responds with ok=False.""" """Should return False when Telegram responds with ok=False."""
mock_resp = self._make_mock_response({"ok": False}) mock_resp = self._make_mock_response({"ok": False})
with patch("apps.handlers.log_streamer.urlopen", return_value=mock_resp): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.urlopen", return_value=mock_resp):
result = streamer._send_message("test") result = streamer._send_message("test")
assert result is False assert result is False
@@ -311,7 +311,7 @@ class TestSendMessage:
"""Request should have Content-Type: application/json header.""" """Request should have Content-Type: application/json header."""
mock_resp = self._make_mock_response({"ok": True}) mock_resp = self._make_mock_response({"ok": True})
with patch("apps.handlers.log_streamer.urlopen", return_value=mock_resp) as mock_urlopen: with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.urlopen", return_value=mock_resp) as mock_urlopen:
streamer._send_message("test") streamer._send_message("test")
req = mock_urlopen.call_args[0][0] req = mock_urlopen.call_args[0][0]
@@ -420,7 +420,7 @@ class TestStartStop:
streamer.start() streamer.start()
first_thread = streamer._thread first_thread = streamer._thread
with patch("apps.handlers.log_streamer.logger") as mock_logger: with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.logger") as mock_logger:
streamer.start() streamer.start()
mock_logger.warning.assert_called_once() mock_logger.warning.assert_called_once()
@@ -466,9 +466,9 @@ class TestBaseBotIntegration:
def _patch_base_bot_deps(self, tmp_path): def _patch_base_bot_deps(self, tmp_path):
"""Patch heavy BaseBot dependencies to allow lightweight instantiation.""" """Patch heavy BaseBot dependencies to allow lightweight instantiation."""
patches = [ patches = [
patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path),
patch("apps.handlers.base_bot.signal.signal"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.signal.signal"),
patch("apps.handlers.base_bot.atexit.register"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.atexit.register"),
] ]
for p in patches: for p in patches:
p.start() p.start()
@@ -478,7 +478,7 @@ class TestBaseBotIntegration:
def test_streamer_starts_on_first_message_with_branch_name(self, tmp_path, _patch_base_bot_deps): def test_streamer_starts_on_first_message_with_branch_name(self, tmp_path, _patch_base_bot_deps):
"""When branch_name is set, LogStreamer should start on first message.""" """When branch_name is set, LogStreamer should start on first message."""
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir() workdir.mkdir()
@@ -504,7 +504,7 @@ class TestBaseBotIntegration:
} }
# Patch LogStreamer at the import location in base_bot # Patch LogStreamer at the import location in base_bot
with patch.object(bot, "send_message"), patch("apps.handlers.base_bot.LogStreamer") as MockStreamer: with patch.object(bot, "send_message"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.LogStreamer") as MockStreamer:
mock_instance = MagicMock() mock_instance = MagicMock()
MockStreamer.return_value = mock_instance MockStreamer.return_value = mock_instance
@@ -515,7 +515,7 @@ class TestBaseBotIntegration:
def test_streamer_not_started_when_branch_name_is_none(self, tmp_path, _patch_base_bot_deps): def test_streamer_not_started_when_branch_name_is_none(self, tmp_path, _patch_base_bot_deps):
"""When branch_name is None (base bot), LogStreamer should NOT be created.""" """When branch_name is None (base bot), LogStreamer should NOT be created."""
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir() workdir.mkdir()
@@ -537,13 +537,13 @@ class TestBaseBotIntegration:
} }
} }
with patch.object(bot, "send_message"), patch("apps.handlers.base_bot.LogStreamer") as MockStreamer: with patch.object(bot, "send_message"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.LogStreamer") as MockStreamer:
bot.process_update(fake_update) bot.process_update(fake_update)
MockStreamer.assert_not_called() MockStreamer.assert_not_called()
def test_cleanup_stops_streamer(self, tmp_path, _patch_base_bot_deps): def test_cleanup_stops_streamer(self, tmp_path, _patch_base_bot_deps):
"""BaseBot._cleanup should call stop() on the LogStreamer if it exists.""" """BaseBot._cleanup should call stop() on the LogStreamer if it exists."""
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir() workdir.mkdir()
@@ -566,7 +566,7 @@ class TestBaseBotIntegration:
def test_cleanup_safe_when_no_streamer(self, tmp_path, _patch_base_bot_deps): def test_cleanup_safe_when_no_streamer(self, tmp_path, _patch_base_bot_deps):
"""BaseBot._cleanup should work fine when _log_streamer is None.""" """BaseBot._cleanup should work fine when _log_streamer is None."""
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir() workdir.mkdir()
@@ -24,8 +24,8 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
from apps.handlers.bot_factory import ( # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.bot_factory import (
launch_mirror_session, launch_mirror_session,
start_service, start_service,
) )
@@ -39,9 +39,9 @@ from apps.handlers.bot_factory import ( # type: ignore[import-not-found]
@pytest.fixture @pytest.fixture
def _patch_base_bot_deps(tmp_path): def _patch_base_bot_deps(tmp_path):
patches = [ patches = [
patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path),
patch("apps.handlers.base_bot.signal.signal"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.signal.signal"),
patch("apps.handlers.base_bot.atexit.register"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.atexit.register"),
] ]
for p in patches: for p in patches:
p.start() p.start()
@@ -53,7 +53,7 @@ def _patch_base_bot_deps(tmp_path):
def _make_bot(tmp_path, _patch_base_bot_deps, attach_only=False, shared_session=None): def _make_bot(tmp_path, _patch_base_bot_deps, attach_only=False, shared_session=None):
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir(exist_ok=True) workdir.mkdir(exist_ok=True)
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BaseBot( bot = BaseBot(
bot_id="mirror_test", bot_id="mirror_test",
bot_token="123:FAKETOKEN", bot_token="123:FAKETOKEN",
@@ -83,7 +83,7 @@ class TestLaunchMirrorSession:
side = [no_session, ok, ok, ok] side = [no_session, ok, ok, ok]
with ( with (
patch("apps.handlers.bot_factory.subprocess.run", side_effect=side), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.subprocess.run", side_effect=side),
patch("time.sleep"), patch("time.sleep"),
): ):
result = launch_mirror_session( result = launch_mirror_session(
@@ -110,7 +110,7 @@ class TestLaunchMirrorSession:
return result return result
with ( with (
patch("apps.handlers.bot_factory.subprocess.run", side_effect=_track), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.subprocess.run", side_effect=_track),
patch("time.sleep"), patch("time.sleep"),
): ):
launch_mirror_session( launch_mirror_session(
@@ -127,7 +127,7 @@ class TestLaunchMirrorSession:
"""Returns True without creating if session already exists.""" """Returns True without creating if session already exists."""
has_session = MagicMock(returncode=0) has_session = MagicMock(returncode=0)
with patch("apps.handlers.bot_factory.subprocess.run", return_value=has_session) as mock_run: with patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.subprocess.run", return_value=has_session) as mock_run:
result = launch_mirror_session(session_name="telegram-api", bot_id="api", work_dir="/tmp/test") result = launch_mirror_session(session_name="telegram-api", bot_id="api", work_dir="/tmp/test")
assert result is True assert result is True
@@ -135,7 +135,7 @@ class TestLaunchMirrorSession:
def test_returns_false_when_tmux_not_found(self): def test_returns_false_when_tmux_not_found(self):
"""Returns False when tmux is not installed.""" """Returns False when tmux is not installed."""
with patch("apps.handlers.bot_factory.subprocess.run", side_effect=FileNotFoundError): with patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.subprocess.run", side_effect=FileNotFoundError):
result = launch_mirror_session(session_name="telegram-api", bot_id="api", work_dir="/tmp/test") result = launch_mirror_session(session_name="telegram-api", bot_id="api", work_dir="/tmp/test")
assert result is False assert result is False
@@ -152,7 +152,7 @@ class TestStartService:
def test_starts_systemd_service(self): def test_starts_systemd_service(self):
"""Calls systemctl --user start telegram-bot@{bot_id}.""" """Calls systemctl --user start telegram-bot@{bot_id}."""
mock_result = MagicMock(returncode=0) mock_result = MagicMock(returncode=0)
with patch("apps.handlers.bot_factory.subprocess.run", return_value=mock_result) as mock_run: with patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.subprocess.run", return_value=mock_result) as mock_run:
result = start_service("api") result = start_service("api")
assert result is True assert result is True
@@ -163,7 +163,7 @@ class TestStartService:
def test_returns_false_on_failure(self): def test_returns_false_on_failure(self):
"""Returns False when systemctl returns non-zero.""" """Returns False when systemctl returns non-zero."""
mock_result = MagicMock(returncode=1, stderr="unit not found") mock_result = MagicMock(returncode=1, stderr="unit not found")
with patch("apps.handlers.bot_factory.subprocess.run", return_value=mock_result): with patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.subprocess.run", return_value=mock_result):
assert start_service("api") is False assert start_service("api") is False
def test_returns_false_on_timeout(self): def test_returns_false_on_timeout(self):
@@ -171,7 +171,7 @@ class TestStartService:
import subprocess import subprocess
err = subprocess.TimeoutExpired(cmd="", timeout=10) err = subprocess.TimeoutExpired(cmd="", timeout=10)
with patch("apps.handlers.bot_factory.subprocess.run", side_effect=err): with patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.subprocess.run", side_effect=err):
assert start_service("api") is False assert start_service("api") is False
@@ -189,20 +189,20 @@ class TestCreateBotMirror:
bot_info = {"username": "test_bot", "id": 123} bot_info = {"username": "test_bot", "id": 123}
branch_info = {"name": "api", "path": "/home/test/api"} branch_info = {"name": "api", "path": "/home/test/api"}
patches = [ patches = [
patch("apps.handlers.bot_factory.validate_token", return_value=bot_info), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.validate_token", return_value=bot_info),
patch("apps.handlers.bot_factory.validate_branch", return_value=branch_info), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.validate_branch", return_value=branch_info),
patch("apps.handlers.bot_factory.get_bot", return_value=None), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.get_bot", return_value=None),
patch("apps.handlers.bot_factory.get_bot_by_branch", return_value=None), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.get_bot_by_branch", return_value=None),
patch("apps.handlers.bot_factory.ensure_registry"), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.ensure_registry"),
patch("apps.handlers.bot_factory._api_set_secret"), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory._api_set_secret"),
patch("apps.handlers.bot_factory.register_bot", return_value=True), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.register_bot", return_value=True),
patch("apps.handlers.bot_factory.set_bot_commands"), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.set_bot_commands"),
patch("apps.handlers.bot_factory.build_botfather_commands", return_value=[]), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.build_botfather_commands", return_value=[]),
patch("apps.handlers.bot_factory.enable_service", return_value=True), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.enable_service", return_value=True),
patch("apps.handlers.bot_factory.start_bot_process", return_value=True), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.start_bot_process", return_value=True),
patch("apps.handlers.bot_factory.launch_mirror_session", return_value=True), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.launch_mirror_session", return_value=True),
patch("apps.handlers.bot_factory.start_service", return_value=True), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.start_service", return_value=True),
patch("apps.handlers.bot_factory._BOT_CONFIG_DIR", Path("/tmp/test_bots")), patch("aipass.skills.lib.telegram.apps.handlers.bot_factory._BOT_CONFIG_DIR", Path("/tmp/test_bots")),
] ]
mocks = {} mocks = {}
started = [] started = []
@@ -217,9 +217,9 @@ class TestCreateBotMirror:
def test_config_includes_mirror_fields(self, _mock_create_deps, tmp_path): def test_config_includes_mirror_fields(self, _mock_create_deps, tmp_path):
"""Config written with shared_session, attach_only, chat_id.""" """Config written with shared_session, attach_only, chat_id."""
from apps.handlers.bot_factory import create_bot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.bot_factory import create_bot
with patch("apps.handlers.bot_factory._BOT_CONFIG_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.bot_factory._BOT_CONFIG_DIR", tmp_path):
result = create_bot( result = create_bot(
bot_id="api", bot_id="api",
bot_token="123:FAKE", bot_token="123:FAKE",
@@ -239,9 +239,9 @@ class TestCreateBotMirror:
def test_launches_mirror_session_when_attach_only(self, _mock_create_deps, tmp_path): def test_launches_mirror_session_when_attach_only(self, _mock_create_deps, tmp_path):
"""launch_mirror_session called when shared_session + attach_only.""" """launch_mirror_session called when shared_session + attach_only."""
from apps.handlers.bot_factory import create_bot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.bot_factory import create_bot
with patch("apps.handlers.bot_factory._BOT_CONFIG_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.bot_factory._BOT_CONFIG_DIR", tmp_path):
create_bot( create_bot(
bot_id="api", bot_id="api",
bot_token="123:FAKE", bot_token="123:FAKE",
@@ -254,9 +254,9 @@ class TestCreateBotMirror:
def test_starts_via_systemd_when_mirror(self, _mock_create_deps, tmp_path): def test_starts_via_systemd_when_mirror(self, _mock_create_deps, tmp_path):
"""Mirror bot started via start_service, not start_bot_process.""" """Mirror bot started via start_service, not start_bot_process."""
from apps.handlers.bot_factory import create_bot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.bot_factory import create_bot
with patch("apps.handlers.bot_factory._BOT_CONFIG_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.bot_factory._BOT_CONFIG_DIR", tmp_path):
create_bot( create_bot(
bot_id="api", bot_id="api",
bot_token="123:FAKE", bot_token="123:FAKE",
@@ -270,9 +270,9 @@ class TestCreateBotMirror:
def test_starts_via_popen_when_not_mirror(self, _mock_create_deps, tmp_path): def test_starts_via_popen_when_not_mirror(self, _mock_create_deps, tmp_path):
"""Non-mirror bot still uses start_bot_process.""" """Non-mirror bot still uses start_bot_process."""
from apps.handlers.bot_factory import create_bot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.bot_factory import create_bot
with patch("apps.handlers.bot_factory._BOT_CONFIG_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.bot_factory._BOT_CONFIG_DIR", tmp_path):
create_bot( create_bot(
bot_id="api", bot_id="api",
bot_token="123:FAKE", bot_token="123:FAKE",
@@ -24,7 +24,7 @@ from pathlib import Path
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from apps.handlers.log_streamer import LogStreamer # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.log_streamer import LogStreamer
# ============================================= # =============================================
@@ -37,9 +37,9 @@ def _patch_base_bot_deps(tmp_path):
"""Patch heavy BaseBot dependencies to allow lightweight instantiation.""" """Patch heavy BaseBot dependencies to allow lightweight instantiation."""
sub_file = tmp_path / "monitor_sub.json" sub_file = tmp_path / "monitor_sub.json"
patches = [ patches = [
patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path),
patch("apps.handlers.base_bot.signal.signal"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.signal.signal"),
patch("apps.handlers.base_bot.atexit.register"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.atexit.register"),
] ]
for p in patches: for p in patches:
p.start() p.start()
@@ -50,7 +50,7 @@ def _patch_base_bot_deps(tmp_path):
def _make_bot(tmp_path, _patch_base_bot_deps): def _make_bot(tmp_path, _patch_base_bot_deps):
"""Create a BaseBot with monitor subscription redirected to tmp_path.""" """Create a BaseBot with monitor subscription redirected to tmp_path."""
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir() workdir.mkdir()
@@ -81,7 +81,7 @@ class TestSubscribePersists:
sub_file: Path = _patch_base_bot_deps sub_file: Path = _patch_base_bot_deps
with ( with (
patch.object(bot, "send_message"), patch.object(bot, "send_message"),
patch("apps.handlers.base_bot.LogStreamer") as MockStreamer, patch("aipass.skills.lib.telegram.apps.handlers.base_bot.LogStreamer") as MockStreamer,
): ):
MockStreamer.return_value = MagicMock() MockStreamer.return_value = MagicMock()
bot._monitor_subscribe(42, "default") bot._monitor_subscribe(42, "default")
@@ -109,7 +109,7 @@ class TestSubscribePersists:
bot = _make_bot(tmp_path, _patch_base_bot_deps) bot = _make_bot(tmp_path, _patch_base_bot_deps)
with ( with (
patch.object(bot, "send_message"), patch.object(bot, "send_message"),
patch("apps.handlers.base_bot.LogStreamer") as MockStreamer, patch("aipass.skills.lib.telegram.apps.handlers.base_bot.LogStreamer") as MockStreamer,
): ):
mock_instance = MagicMock() mock_instance = MagicMock()
MockStreamer.return_value = mock_instance MockStreamer.return_value = mock_instance
@@ -130,7 +130,7 @@ class TestSubscribePersists:
bot = _make_bot(tmp_path, _patch_base_bot_deps) bot = _make_bot(tmp_path, _patch_base_bot_deps)
with ( with (
patch.object(bot, "send_message") as mock_send, patch.object(bot, "send_message") as mock_send,
patch("apps.handlers.base_bot.LogStreamer", return_value=MagicMock()), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.LogStreamer", return_value=MagicMock()),
): ):
bot._monitor_subscribe(42, "default") bot._monitor_subscribe(42, "default")
mock_send.assert_called_once() mock_send.assert_called_once()
@@ -144,7 +144,7 @@ class TestSubscribePersists:
with ( with (
patch.object(bot, "send_message"), patch.object(bot, "send_message"),
patch("apps.handlers.base_bot.LogStreamer", return_value=MagicMock()), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.LogStreamer", return_value=MagicMock()),
): ):
bot._monitor_subscribe(42, "all") bot._monitor_subscribe(42, "all")
old_streamer.stop.assert_called_once() old_streamer.stop.assert_called_once()
@@ -176,7 +176,7 @@ class TestBootMonitor:
sub_file.write_text(json.dumps({"chat_id": 42, "mode": "default"})) sub_file.write_text(json.dumps({"chat_id": 42, "mode": "default"}))
with patch("apps.handlers.base_bot.LogStreamer") as MockStreamer: with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.LogStreamer") as MockStreamer:
mock_instance = MagicMock() mock_instance = MagicMock()
MockStreamer.return_value = mock_instance MockStreamer.return_value = mock_instance
@@ -195,7 +195,7 @@ class TestBootMonitor:
def test_boot_noop_when_no_subscription(self, tmp_path, _patch_base_bot_deps): def test_boot_noop_when_no_subscription(self, tmp_path, _patch_base_bot_deps):
bot = _make_bot(tmp_path, _patch_base_bot_deps) bot = _make_bot(tmp_path, _patch_base_bot_deps)
# No file written — subscription absent # No file written — subscription absent
with patch("apps.handlers.base_bot.LogStreamer") as MockStreamer: with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.LogStreamer") as MockStreamer:
bot._boot_monitor() bot._boot_monitor()
MockStreamer.assert_not_called() MockStreamer.assert_not_called()
assert bot._monitor_streamer is None assert bot._monitor_streamer is None
@@ -205,7 +205,7 @@ class TestBootMonitor:
sub_file: Path = _patch_base_bot_deps sub_file: Path = _patch_base_bot_deps
sub_file.write_text("{}") sub_file.write_text("{}")
with patch("apps.handlers.base_bot.LogStreamer") as MockStreamer: with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.LogStreamer") as MockStreamer:
bot._boot_monitor() bot._boot_monitor()
MockStreamer.assert_not_called() MockStreamer.assert_not_called()
@@ -216,7 +216,7 @@ class TestBootMonitor:
sub_file.write_text(json.dumps({"chat_id": 99, "mode": "all"})) sub_file.write_text(json.dumps({"chat_id": 99, "mode": "all"}))
with patch("apps.handlers.base_bot.LogStreamer") as MockStreamer: with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.LogStreamer") as MockStreamer:
MockStreamer.return_value = MagicMock() MockStreamer.return_value = MagicMock()
bot._boot_monitor() bot._boot_monitor()
MockStreamer.assert_called_once_with( MockStreamer.assert_called_once_with(
@@ -240,14 +240,14 @@ class TestLevelFilter:
def streamer_default(self, tmp_path): def streamer_default(self, tmp_path):
logs_dir = tmp_path / "system_logs" logs_dir = tmp_path / "system_logs"
logs_dir.mkdir() logs_dir.mkdir()
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
return LogStreamer("tok", 1, "x", system_wide=True, level_filter="default") return LogStreamer("tok", 1, "x", system_wide=True, level_filter="default")
@pytest.fixture @pytest.fixture
def streamer_all(self, tmp_path): def streamer_all(self, tmp_path):
logs_dir = tmp_path / "system_logs" logs_dir = tmp_path / "system_logs"
logs_dir.mkdir() logs_dir.mkdir()
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
return LogStreamer("tok", 1, "x", system_wide=True, level_filter="all") return LogStreamer("tok", 1, "x", system_wide=True, level_filter="all")
def test_default_keeps_warning(self, streamer_default): def test_default_keeps_warning(self, streamer_default):
@@ -306,7 +306,7 @@ class TestSystemWideGlob:
(logs_dir / "prax_main.log").write_text("b\n") (logs_dir / "prax_main.log").write_text("b\n")
(logs_dir / "trigger_events.log").write_text("c\n") (logs_dir / "trigger_events.log").write_text("c\n")
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
s = LogStreamer("tok", 1, "monitor", system_wide=True, level_filter="all") s = LogStreamer("tok", 1, "monitor", system_wide=True, level_filter="all")
assert len(s.log_positions) == 3 assert len(s.log_positions) == 3
@@ -317,7 +317,7 @@ class TestSystemWideGlob:
(logs_dir / "api_main.log").write_text("a\n") (logs_dir / "api_main.log").write_text("a\n")
(logs_dir / "prax_main.log").write_text("b\n") (logs_dir / "prax_main.log").write_text("b\n")
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
s = LogStreamer("tok", 1, "api", system_wide=False, level_filter="all") s = LogStreamer("tok", 1, "api", system_wide=False, level_filter="all")
assert len(s.log_positions) == 1 assert len(s.log_positions) == 1
@@ -331,13 +331,13 @@ class TestSystemWideGlob:
f1.write_text("") f1.write_text("")
f2.write_text("") f2.write_text("")
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
s = LogStreamer("tok", 1, "monitor", system_wide=True, level_filter="all") s = LogStreamer("tok", 1, "monitor", system_wide=True, level_filter="all")
f1.write_text("api line\n") f1.write_text("api line\n")
f2.write_text("prax line\n") f2.write_text("prax line\n")
with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): with patch("aipass.skills.lib.telegram.apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir):
lines = s._read_new_lines() lines = s._read_new_lines()
assert "api line" in lines assert "api line" in lines
@@ -33,8 +33,8 @@ import time
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
from apps.handlers.branch_plugin import BranchPlugin # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.branch_plugin import BranchPlugin
# ============================================= # =============================================
@@ -47,7 +47,7 @@ def base_bot(tmp_path):
"""Create a BaseBot instance with PENDING_DIR pointed at tmp_path.""" """Create a BaseBot instance with PENDING_DIR pointed at tmp_path."""
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir() workdir.mkdir()
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BaseBot( bot = BaseBot(
bot_id="test_bot", bot_id="test_bot",
bot_token="123:FAKETOKEN", bot_token="123:FAKETOKEN",
@@ -65,7 +65,7 @@ def base_bot_open(tmp_path):
"""Create a BaseBot with an empty allowlist (allows everyone).""" """Create a BaseBot with an empty allowlist (allows everyone)."""
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir() workdir.mkdir()
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BaseBot( bot = BaseBot(
bot_id="open_bot", bot_id="open_bot",
bot_token="456:FAKETOKEN", bot_token="456:FAKETOKEN",
@@ -82,7 +82,7 @@ def branch_bot(tmp_path):
"""Create a BranchPlugin instance.""" """Create a BranchPlugin instance."""
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir() workdir.mkdir()
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BranchPlugin( bot = BranchPlugin(
branch_name="dev_central", branch_name="dev_central",
bot_id="dev_central", bot_id="dev_central",
@@ -141,7 +141,7 @@ class TestBaseBotInit:
assert base_bot.custom_commands == {} assert base_bot.custom_commands == {}
def test_custom_commands_set(self, tmp_path): def test_custom_commands_set(self, tmp_path):
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BaseBot( bot = BaseBot(
bot_id="cmd_bot", bot_id="cmd_bot",
bot_token="t", bot_token="t",
@@ -151,7 +151,7 @@ class TestBaseBotInit:
assert bot.custom_commands == {"ping": "Pong!"} assert bot.custom_commands == {"ping": "Pong!"}
def test_allowed_user_ids_none_becomes_empty_list(self, tmp_path): def test_allowed_user_ids_none_becomes_empty_list(self, tmp_path):
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BaseBot( bot = BaseBot(
bot_id="none_bot", bot_id="none_bot",
bot_token="t", bot_token="t",
@@ -318,14 +318,14 @@ class TestWritePendingFile:
"""Test pending file creation with correct JSON content.""" """Test pending file creation with correct JSON content."""
def test_write_creates_file(self, base_bot, tmp_path): def test_write_creates_file(self, base_bot, tmp_path):
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
with patch.object(base_bot, "_get_transcript_line_count", return_value=42): with patch.object(base_bot, "_get_transcript_line_count", return_value=42):
result = base_bot.write_pending_file(chat_id=12345, message_id=100, processing_message_id=101) result = base_bot.write_pending_file(chat_id=12345, message_id=100, processing_message_id=101)
assert result is True assert result is True
assert base_bot.pending_file.exists() assert base_bot.pending_file.exists()
def test_write_correct_json_content(self, base_bot, tmp_path): def test_write_correct_json_content(self, base_bot, tmp_path):
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
with patch.object(base_bot, "_get_transcript_line_count", return_value=10): with patch.object(base_bot, "_get_transcript_line_count", return_value=10):
base_bot.write_pending_file(chat_id=12345, message_id=100, processing_message_id=101) base_bot.write_pending_file(chat_id=12345, message_id=100, processing_message_id=101)
data = json.loads(base_bot.pending_file.read_text()) data = json.loads(base_bot.pending_file.read_text())
@@ -339,14 +339,14 @@ class TestWritePendingFile:
assert isinstance(data["timestamp"], float) assert isinstance(data["timestamp"], float)
def test_write_with_none_processing_id(self, base_bot, tmp_path): def test_write_with_none_processing_id(self, base_bot, tmp_path):
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
with patch.object(base_bot, "_get_transcript_line_count", return_value=0): with patch.object(base_bot, "_get_transcript_line_count", return_value=0):
base_bot.write_pending_file(chat_id=12345, message_id=100, processing_message_id=None) base_bot.write_pending_file(chat_id=12345, message_id=100, processing_message_id=None)
data = json.loads(base_bot.pending_file.read_text()) data = json.loads(base_bot.pending_file.read_text())
assert data["processing_message_id"] is None assert data["processing_message_id"] is None
def test_write_includes_work_dir(self, base_bot, tmp_path): def test_write_includes_work_dir(self, base_bot, tmp_path):
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
with patch.object(base_bot, "_get_transcript_line_count", return_value=0): with patch.object(base_bot, "_get_transcript_line_count", return_value=0):
base_bot.write_pending_file(chat_id=1, message_id=1) base_bot.write_pending_file(chat_id=1, message_id=1)
data = json.loads(base_bot.pending_file.read_text()) data = json.loads(base_bot.pending_file.read_text())
@@ -361,7 +361,7 @@ class TestWritePendingFile:
class TestHeartbeat: class TestHeartbeat:
"""Test heartbeat thread updates the processing message with elapsed time.""" """Test heartbeat thread updates the processing message with elapsed time."""
@patch("apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1)
def test_heartbeat_calls_edit_message(self, base_bot, tmp_path): def test_heartbeat_calls_edit_message(self, base_bot, tmp_path):
"""Verify heartbeat updates the message with elapsed time text.""" """Verify heartbeat updates the message with elapsed time text."""
# Create the pending file so heartbeat doesn't exit early # Create the pending file so heartbeat doesn't exit early
@@ -384,7 +384,7 @@ class TestHeartbeat:
assert first_call[0][1] == 101 # message_id assert first_call[0][1] == 101 # message_id
assert "Processing..." in first_call[0][2] assert "Processing..." in first_call[0][2]
@patch("apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1)
def test_heartbeat_stops_when_pending_removed(self, base_bot, tmp_path): def test_heartbeat_stops_when_pending_removed(self, base_bot, tmp_path):
"""Heartbeat should exit when pending file is removed.""" """Heartbeat should exit when pending file is removed."""
# Create then immediately remove pending file # Create then immediately remove pending file
@@ -431,30 +431,30 @@ class TestVerifyConnection:
mock_resp.__exit__ = MagicMock(return_value=False) mock_resp.__exit__ = MagicMock(return_value=False)
return mock_resp return mock_resp
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_verify_success(self, mock_urlopen, base_bot): def test_verify_success(self, mock_urlopen, base_bot):
mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": {"username": "test_bot"}}) mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": {"username": "test_bot"}})
assert base_bot.verify_connection() is True assert base_bot.verify_connection() is True
mock_urlopen.assert_called_once() mock_urlopen.assert_called_once()
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_verify_api_rejected(self, mock_urlopen, base_bot): def test_verify_api_rejected(self, mock_urlopen, base_bot):
mock_urlopen.return_value = self._make_mock_response({"ok": False, "description": "Unauthorized"}) mock_urlopen.return_value = self._make_mock_response({"ok": False, "description": "Unauthorized"})
assert base_bot.verify_connection() is False assert base_bot.verify_connection() is False
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_verify_network_error(self, mock_urlopen, base_bot): def test_verify_network_error(self, mock_urlopen, base_bot):
from urllib.error import URLError from urllib.error import URLError
mock_urlopen.side_effect = URLError("Connection refused") mock_urlopen.side_effect = URLError("Connection refused")
assert base_bot.verify_connection() is False assert base_bot.verify_connection() is False
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_verify_unexpected_exception(self, mock_urlopen, base_bot): def test_verify_unexpected_exception(self, mock_urlopen, base_bot):
mock_urlopen.side_effect = RuntimeError("unexpected") mock_urlopen.side_effect = RuntimeError("unexpected")
assert base_bot.verify_connection() is False assert base_bot.verify_connection() is False
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_verify_uses_correct_url(self, mock_urlopen, base_bot): def test_verify_uses_correct_url(self, mock_urlopen, base_bot):
mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": {"username": "test_bot"}}) mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": {"username": "test_bot"}})
base_bot.verify_connection() base_bot.verify_connection()
@@ -478,27 +478,27 @@ class TestSendMessage:
mock_resp.__exit__ = MagicMock(return_value=False) mock_resp.__exit__ = MagicMock(return_value=False)
return mock_resp return mock_resp
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_send_success(self, mock_urlopen, base_bot): def test_send_success(self, mock_urlopen, base_bot):
mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": {"message_id": 42}}) mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": {"message_id": 42}})
result = base_bot.send_message(12345, "Hello!") result = base_bot.send_message(12345, "Hello!")
assert result == {"message_id": 42} assert result == {"message_id": 42}
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_send_returns_none_on_failure(self, mock_urlopen, base_bot): def test_send_returns_none_on_failure(self, mock_urlopen, base_bot):
mock_urlopen.side_effect = RuntimeError("fail") mock_urlopen.side_effect = RuntimeError("fail")
result = base_bot.send_message(12345, "Hello!") result = base_bot.send_message(12345, "Hello!")
assert result is None assert result is None
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_send_retries_on_failure(self, mock_urlopen, base_bot): def test_send_retries_on_failure(self, mock_urlopen, base_bot):
"""send_message retries up to 3 times.""" """send_message retries up to 3 times."""
mock_urlopen.side_effect = RuntimeError("fail") mock_urlopen.side_effect = RuntimeError("fail")
with patch("apps.handlers.base_bot.time.sleep"): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.time.sleep"):
base_bot.send_message(12345, "Hello!") base_bot.send_message(12345, "Hello!")
assert mock_urlopen.call_count == 3 assert mock_urlopen.call_count == 3
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_send_with_reply_to(self, mock_urlopen, base_bot): def test_send_with_reply_to(self, mock_urlopen, base_bot):
mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": {"message_id": 43}}) mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": {"message_id": 43}})
result = base_bot.send_message(12345, "reply", reply_to=10) result = base_bot.send_message(12345, "reply", reply_to=10)
@@ -507,7 +507,7 @@ class TestSendMessage:
sent_data = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8")) sent_data = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
assert sent_data["reply_to_message_id"] == 10 assert sent_data["reply_to_message_id"] == 10
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_send_uses_correct_url(self, mock_urlopen, base_bot): def test_send_uses_correct_url(self, mock_urlopen, base_bot):
mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": {"message_id": 1}}) mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": {"message_id": 1}})
base_bot.send_message(12345, "test") base_bot.send_message(12345, "test")
@@ -526,25 +526,25 @@ class TestEditMessage:
mock_resp.__exit__ = MagicMock(return_value=False) mock_resp.__exit__ = MagicMock(return_value=False)
return mock_resp return mock_resp
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_edit_success(self, mock_urlopen, base_bot): def test_edit_success(self, mock_urlopen, base_bot):
mock_urlopen.return_value = self._make_mock_response({"ok": True}) mock_urlopen.return_value = self._make_mock_response({"ok": True})
result = base_bot.edit_message(12345, 42, "Updated text") result = base_bot.edit_message(12345, 42, "Updated text")
assert result is True assert result is True
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_edit_failure(self, mock_urlopen, base_bot): def test_edit_failure(self, mock_urlopen, base_bot):
mock_urlopen.return_value = self._make_mock_response({"ok": False, "description": "Message not modified"}) mock_urlopen.return_value = self._make_mock_response({"ok": False, "description": "Message not modified"})
result = base_bot.edit_message(12345, 42, "Same text") result = base_bot.edit_message(12345, 42, "Same text")
assert result is False assert result is False
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_edit_exception(self, mock_urlopen, base_bot): def test_edit_exception(self, mock_urlopen, base_bot):
mock_urlopen.side_effect = RuntimeError("network error") mock_urlopen.side_effect = RuntimeError("network error")
result = base_bot.edit_message(12345, 42, "text") result = base_bot.edit_message(12345, 42, "text")
assert result is False assert result is False
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_edit_sends_correct_payload(self, mock_urlopen, base_bot): def test_edit_sends_correct_payload(self, mock_urlopen, base_bot):
mock_urlopen.return_value = self._make_mock_response({"ok": True}) mock_urlopen.return_value = self._make_mock_response({"ok": True})
base_bot.edit_message(12345, 42, "new text") base_bot.edit_message(12345, 42, "new text")
@@ -553,7 +553,7 @@ class TestEditMessage:
assert sent_data["message_id"] == 42 assert sent_data["message_id"] == 42
assert sent_data["text"] == "new text" assert sent_data["text"] == "new text"
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_edit_uses_correct_url(self, mock_urlopen, base_bot): def test_edit_uses_correct_url(self, mock_urlopen, base_bot):
mock_urlopen.return_value = self._make_mock_response({"ok": True}) mock_urlopen.return_value = self._make_mock_response({"ok": True})
base_bot.edit_message(12345, 42, "x") base_bot.edit_message(12345, 42, "x")
@@ -569,8 +569,8 @@ class TestEditMessage:
class TestEnsureTmuxSession: class TestEnsureTmuxSession:
"""Test tmux session creation with mocked subprocess.""" """Test tmux session creation with mocked subprocess."""
@patch("apps.handlers.base_bot.time.sleep") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.time.sleep")
@patch("apps.handlers.base_bot.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.subprocess.run")
def test_session_exists_returns_true(self, mock_run, mock_sleep, base_bot): def test_session_exists_returns_true(self, mock_run, mock_sleep, base_bot):
"""If tmux session already exists, return True without creating.""" """If tmux session already exists, return True without creating."""
# has-session returns 0 (session exists) # has-session returns 0 (session exists)
@@ -581,8 +581,8 @@ class TestEnsureTmuxSession:
mock_run.assert_called_once() mock_run.assert_called_once()
assert "has-session" in mock_run.call_args[0][0] assert "has-session" in mock_run.call_args[0][0]
@patch("apps.handlers.base_bot.time.sleep") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.time.sleep")
@patch("apps.handlers.base_bot.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.subprocess.run")
def test_no_session_returns_false(self, mock_run, mock_sleep, base_bot): def test_no_session_returns_false(self, mock_run, mock_sleep, base_bot):
"""When no session exists, bot returns False (never spawns own brain).""" """When no session exists, bot returns False (never spawns own brain)."""
mock_run.return_value = MagicMock(returncode=1) mock_run.return_value = MagicMock(returncode=1)
@@ -596,7 +596,7 @@ class TestEnsureTmuxSession:
def test_session_refuses_nonexistent_work_dir(self, tmp_path): def test_session_refuses_nonexistent_work_dir(self, tmp_path):
"""When work_dir doesn't exist, ensure_tmux_session returns False.""" """When work_dir doesn't exist, ensure_tmux_session returns False."""
bad_dir = tmp_path / "nonexistent" bad_dir = tmp_path / "nonexistent"
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BaseBot( bot = BaseBot(
bot_id="bad_dir_bot", bot_id="bad_dir_bot",
bot_token="t", bot_token="t",
@@ -607,7 +607,7 @@ class TestEnsureTmuxSession:
def test_tmux_not_found_returns_false(self, base_bot): def test_tmux_not_found_returns_false(self, base_bot):
"""FileNotFoundError (tmux not installed) returns False.""" """FileNotFoundError (tmux not installed) returns False."""
with patch("apps.handlers.base_bot.subprocess.run", side_effect=FileNotFoundError("tmux")): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.subprocess.run", side_effect=FileNotFoundError("tmux")):
result = base_bot.ensure_tmux_session() result = base_bot.ensure_tmux_session()
assert result is False assert result is False
@@ -620,15 +620,15 @@ class TestEnsureTmuxSession:
class TestInjectMessage: class TestInjectMessage:
"""Test tmux send-keys injection with mocked subprocess.""" """Test tmux send-keys injection with mocked subprocess."""
@patch("apps.handlers.base_bot.time.sleep") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.time.sleep")
@patch("apps.handlers.base_bot.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.subprocess.run")
def test_inject_success(self, mock_run, mock_sleep, base_bot): def test_inject_success(self, mock_run, mock_sleep, base_bot):
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
result = base_bot.inject_message("hello world") result = base_bot.inject_message("hello world")
assert result is True assert result is True
@patch("apps.handlers.base_bot.time.sleep") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.time.sleep")
@patch("apps.handlers.base_bot.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.subprocess.run")
def test_inject_calls_send_keys_with_text_then_enter(self, mock_run, mock_sleep, base_bot): def test_inject_calls_send_keys_with_text_then_enter(self, mock_run, mock_sleep, base_bot):
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
base_bot.inject_message("test message") base_bot.inject_message("test message")
@@ -647,16 +647,16 @@ class TestInjectMessage:
assert "send-keys" in second_cmd assert "send-keys" in second_cmd
assert "Enter" in second_cmd assert "Enter" in second_cmd
@patch("apps.handlers.base_bot.time.sleep") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.time.sleep")
@patch("apps.handlers.base_bot.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.subprocess.run")
def test_inject_uses_correct_session_name(self, mock_run, mock_sleep, base_bot): def test_inject_uses_correct_session_name(self, mock_run, mock_sleep, base_bot):
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
base_bot.inject_message("x") base_bot.inject_message("x")
first_cmd = mock_run.call_args_list[0][0][0] first_cmd = mock_run.call_args_list[0][0][0]
assert base_bot.session_name in first_cmd assert base_bot.session_name in first_cmd
@patch("apps.handlers.base_bot.time.sleep") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.time.sleep")
@patch("apps.handlers.base_bot.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.subprocess.run")
def test_inject_failure(self, mock_run, mock_sleep, base_bot): def test_inject_failure(self, mock_run, mock_sleep, base_bot):
import subprocess as sp import subprocess as sp
@@ -664,8 +664,8 @@ class TestInjectMessage:
result = base_bot.inject_message("hello") result = base_bot.inject_message("hello")
assert result is False assert result is False
@patch("apps.handlers.base_bot.time.sleep") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.time.sleep")
@patch("apps.handlers.base_bot.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.subprocess.run")
def test_inject_sleeps_between_commands(self, mock_run, mock_sleep, base_bot): def test_inject_sleeps_between_commands(self, mock_run, mock_sleep, base_bot):
"""Verify there's a delay between sending text and pressing Enter.""" """Verify there's a delay between sending text and pressing Enter."""
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
@@ -689,20 +689,20 @@ class TestPollUpdates:
mock_resp.__exit__ = MagicMock(return_value=False) mock_resp.__exit__ = MagicMock(return_value=False)
return mock_resp return mock_resp
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_poll_returns_updates(self, mock_urlopen, base_bot): def test_poll_returns_updates(self, mock_urlopen, base_bot):
updates = [{"update_id": 1, "message": {"text": "hi"}}] updates = [{"update_id": 1, "message": {"text": "hi"}}]
mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": updates}) mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": updates})
result = base_bot.poll_updates(0) result = base_bot.poll_updates(0)
assert result == updates assert result == updates
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_poll_returns_empty_on_error(self, mock_urlopen, base_bot): def test_poll_returns_empty_on_error(self, mock_urlopen, base_bot):
mock_urlopen.return_value = self._make_mock_response({"ok": False, "description": "Bad Request"}) mock_urlopen.return_value = self._make_mock_response({"ok": False, "description": "Bad Request"})
result = base_bot.poll_updates(0) result = base_bot.poll_updates(0)
assert result == [] assert result == []
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_poll_returns_empty_on_exception(self, mock_urlopen, base_bot): def test_poll_returns_empty_on_exception(self, mock_urlopen, base_bot):
from urllib.error import URLError from urllib.error import URLError
@@ -710,7 +710,7 @@ class TestPollUpdates:
result = base_bot.poll_updates(0) result = base_bot.poll_updates(0)
assert result == [] assert result == []
@patch("apps.handlers.base_bot.urlopen") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen")
def test_poll_includes_offset_in_url(self, mock_urlopen, base_bot): def test_poll_includes_offset_in_url(self, mock_urlopen, base_bot):
mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": []}) mock_urlopen.return_value = self._make_mock_response({"ok": True, "result": []})
base_bot.poll_updates(42) base_bot.poll_updates(42)
@@ -791,7 +791,7 @@ class TestProcessUpdate:
}, },
} }
with patch.object(base_bot, "handle_message") as mock_handle: with patch.object(base_bot, "handle_message") as mock_handle:
with patch("apps.handlers.base_bot.parse_command", return_value=None): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.parse_command", return_value=None):
base_bot.process_update(update) base_bot.process_update(update)
mock_handle.assert_called_once() mock_handle.assert_called_once()
@@ -901,7 +901,7 @@ class TestCreateCommand:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_bot(self, tmp_path): def setup_bot(self, tmp_path):
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
self.bot = BaseBot( self.bot = BaseBot(
bot_id="test", bot_id="test",
bot_token="123:FAKE", bot_token="123:FAKE",
@@ -912,9 +912,9 @@ class TestCreateCommand:
self.bot.send_message = MagicMock(return_value={"message_id": 42}) self.bot.send_message = MagicMock(return_value={"message_id": 42})
self.chat_id = 12345 self.chat_id = 12345
@patch("apps.handlers.base_bot.check_telethon_setup", return_value=(False, "not configured")) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.check_telethon_setup", return_value=(False, "not configured"))
@patch("apps.handlers.base_bot.get_bot_by_branch", return_value=None) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.get_bot_by_branch", return_value=None)
@patch("apps.handlers.base_bot.validate_branch") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_branch")
def test_create_valid_branch(self, mock_validate, mock_get_bot, mock_telethon): def test_create_valid_branch(self, mock_validate, mock_get_bot, mock_telethon):
mock_validate.return_value = {"name": "dev_central", "path": "/home/aipass/dev_central"} mock_validate.return_value = {"name": "dev_central", "path": "/home/aipass/dev_central"}
self.bot._handle_create_command(self.chat_id, "chat dev_central") self.bot._handle_create_command(self.chat_id, "chat dev_central")
@@ -936,15 +936,15 @@ class TestCreateCommand:
msg = self.bot.send_message.call_args[0][1] msg = self.bot.send_message.call_args[0][1]
assert "Usage" in msg assert "Usage" in msg
@patch("apps.handlers.base_bot.validate_branch", return_value=None) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_branch", return_value=None)
def test_create_branch_not_found(self, mock_validate): def test_create_branch_not_found(self, mock_validate):
self.bot._handle_create_command(self.chat_id, "chat nonexistent") self.bot._handle_create_command(self.chat_id, "chat nonexistent")
self.bot.send_message.assert_called_once() self.bot.send_message.assert_called_once()
msg = self.bot.send_message.call_args[0][1] msg = self.bot.send_message.call_args[0][1]
assert "not found" in msg assert "not found" in msg
@patch("apps.handlers.base_bot.get_bot_by_branch") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.get_bot_by_branch")
@patch("apps.handlers.base_bot.validate_branch") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_branch")
def test_create_branch_already_has_bot(self, mock_validate, mock_get_bot): def test_create_branch_already_has_bot(self, mock_validate, mock_get_bot):
mock_validate.return_value = {"name": "dev_central", "path": "/tmp"} mock_validate.return_value = {"name": "dev_central", "path": "/tmp"}
mock_get_bot.return_value = {"bot_id": "dev_central", "username": "dc_bot"} mock_get_bot.return_value = {"bot_id": "dev_central", "username": "dc_bot"}
@@ -953,8 +953,8 @@ class TestCreateCommand:
msg = self.bot.send_message.call_args[0][1] msg = self.bot.send_message.call_args[0][1]
assert "already has a bot" in msg assert "already has a bot" in msg
@patch("apps.handlers.base_bot.get_bot_by_branch", return_value=None) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.get_bot_by_branch", return_value=None)
@patch("apps.handlers.base_bot.validate_branch") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_branch")
def test_create_sets_state_with_branch_info(self, mock_validate, mock_get_bot): def test_create_sets_state_with_branch_info(self, mock_validate, mock_get_bot):
mock_validate.return_value = {"name": "flow", "path": "/home/aipass/flow"} mock_validate.return_value = {"name": "flow", "path": "/home/aipass/flow"}
self.bot._handle_create_command(self.chat_id, "chat @flow") self.bot._handle_create_command(self.chat_id, "chat @flow")
@@ -971,8 +971,8 @@ class TestCreateCommand:
msg = self.bot.send_message.call_args[0][1] msg = self.bot.send_message.call_args[0][1]
assert "Usage" in msg assert "Usage" in msg
@patch("apps.handlers.base_bot.get_bot_by_branch", return_value=None) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.get_bot_by_branch", return_value=None)
@patch("apps.handlers.base_bot.validate_branch") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_branch")
def test_create_strips_at_symbol(self, mock_validate, mock_get_bot): def test_create_strips_at_symbol(self, mock_validate, mock_get_bot):
"""Branch name should have @ stripped before lookup.""" """Branch name should have @ stripped before lookup."""
mock_validate.return_value = {"name": "seed", "path": "/home/aipass/seed"} mock_validate.return_value = {"name": "seed", "path": "/home/aipass/seed"}
@@ -990,7 +990,7 @@ class TestCreateToken:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_bot(self, tmp_path): def setup_bot(self, tmp_path):
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
self.bot = BaseBot( self.bot = BaseBot(
bot_id="test", bot_id="test",
bot_token="123:FAKE", bot_token="123:FAKE",
@@ -1009,8 +1009,8 @@ class TestCreateToken:
"started_at": started_at or time.time(), "started_at": started_at or time.time(),
} }
@patch("apps.handlers.base_bot.create_bot") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot")
@patch("apps.handlers.base_bot.validate_token") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_token")
def test_valid_token_creates_bot(self, mock_validate_token, mock_create_bot): def test_valid_token_creates_bot(self, mock_validate_token, mock_create_bot):
self._set_create_state() self._set_create_state()
mock_validate_token.return_value = {"username": "my_new_bot"} mock_validate_token.return_value = {"username": "my_new_bot"}
@@ -1036,7 +1036,7 @@ class TestCreateToken:
msg = self.bot.send_message.call_args[0][1] msg = self.bot.send_message.call_args[0][1]
assert "doesn't look like a valid" in msg assert "doesn't look like a valid" in msg
@patch("apps.handlers.base_bot.validate_token", return_value=None) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_token", return_value=None)
def test_token_validation_fails(self, mock_validate_token): def test_token_validation_fails(self, mock_validate_token):
self._set_create_state() self._set_create_state()
self.bot._handle_create_token(self.chat_id, "123456789:ABCdefGHIjklMNOpqr") self.bot._handle_create_token(self.chat_id, "123456789:ABCdefGHIjklMNOpqr")
@@ -1044,8 +1044,8 @@ class TestCreateToken:
msg = self.bot.send_message.call_args[0][1] msg = self.bot.send_message.call_args[0][1]
assert "validation failed" in msg.lower() assert "validation failed" in msg.lower()
@patch("apps.handlers.base_bot.create_bot", return_value=None) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot", return_value=None)
@patch("apps.handlers.base_bot.validate_token") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_token")
def test_bot_creation_fails(self, mock_validate_token, mock_create_bot): def test_bot_creation_fails(self, mock_validate_token, mock_create_bot):
self._set_create_state() self._set_create_state()
mock_validate_token.return_value = {"username": "test_bot"} mock_validate_token.return_value = {"username": "test_bot"}
@@ -1062,8 +1062,8 @@ class TestCreateToken:
assert "expired" in msg.lower() assert "expired" in msg.lower()
assert self.chat_id not in self.bot._create_state assert self.chat_id not in self.bot._create_state
@patch("apps.handlers.base_bot.create_bot") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot")
@patch("apps.handlers.base_bot.validate_token") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_token")
def test_state_cleared_after_success(self, mock_validate_token, mock_create_bot): def test_state_cleared_after_success(self, mock_validate_token, mock_create_bot):
self._set_create_state() self._set_create_state()
mock_validate_token.return_value = {"username": "new_bot"} mock_validate_token.return_value = {"username": "new_bot"}
@@ -1071,8 +1071,8 @@ class TestCreateToken:
self.bot._handle_create_token(self.chat_id, "123456789:ABCdefGHIjklMNOpqr") self.bot._handle_create_token(self.chat_id, "123456789:ABCdefGHIjklMNOpqr")
assert self.chat_id not in self.bot._create_state assert self.chat_id not in self.bot._create_state
@patch("apps.handlers.base_bot.create_bot", return_value=None) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot", return_value=None)
@patch("apps.handlers.base_bot.validate_token") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_token")
def test_state_cleared_after_failure(self, mock_validate_token, mock_create_bot): def test_state_cleared_after_failure(self, mock_validate_token, mock_create_bot):
"""State should be cleared even when bot creation fails (after token validated).""" """State should be cleared even when bot creation fails (after token validated)."""
self._set_create_state() self._set_create_state()
@@ -1092,7 +1092,7 @@ class TestCancelCommand:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_bot(self, tmp_path): def setup_bot(self, tmp_path):
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
self.bot = BaseBot( self.bot = BaseBot(
bot_id="test", bot_id="test",
bot_token="123:FAKE", bot_token="123:FAKE",
@@ -1119,7 +1119,7 @@ class TestCancelCommand:
"message_id": 1, "message_id": 1,
}, },
} }
with patch("apps.handlers.base_bot.parse_command", return_value=("cancel", "")): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.parse_command", return_value=("cancel", "")):
self.bot.process_update(update) self.bot.process_update(update)
assert self.chat_id not in self.bot._create_state assert self.chat_id not in self.bot._create_state
msg = self.bot.send_message.call_args[0][1] msg = self.bot.send_message.call_args[0][1]
@@ -1135,7 +1135,7 @@ class TestCancelCommand:
"message_id": 1, "message_id": 1,
}, },
} }
with patch("apps.handlers.base_bot.parse_command", return_value=("cancel", "")): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.parse_command", return_value=("cancel", "")):
self.bot.process_update(update) self.bot.process_update(update)
msg = self.bot.send_message.call_args[0][1] msg = self.bot.send_message.call_args[0][1]
assert "Nothing to cancel" in msg assert "Nothing to cancel" in msg
@@ -1162,7 +1162,7 @@ class TestCancelCommand:
"message_id": 1, "message_id": 1,
}, },
} }
with patch("apps.handlers.base_bot.parse_command", return_value=("cancel", "")): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.parse_command", return_value=("cancel", "")):
self.bot.process_update(update) self.bot.process_update(update)
assert self.chat_id not in self.bot._create_state assert self.chat_id not in self.bot._create_state
assert other_chat in self.bot._create_state assert other_chat in self.bot._create_state
@@ -1178,7 +1178,7 @@ class TestStatusWithRegistry:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_bot(self, tmp_path): def setup_bot(self, tmp_path):
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
self.bot = BaseBot( self.bot = BaseBot(
bot_id="test", bot_id="test",
bot_token="123:FAKE", bot_token="123:FAKE",
@@ -1189,7 +1189,7 @@ class TestStatusWithRegistry:
self.bot.send_message = MagicMock(return_value={"message_id": 42}) self.bot.send_message = MagicMock(return_value={"message_id": 42})
self.chat_id = 12345 self.chat_id = 12345
@patch("apps.handlers.base_bot.registry_list_bots") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.registry_list_bots")
def test_status_includes_registry(self, mock_list_bots): def test_status_includes_registry(self, mock_list_bots):
mock_list_bots.return_value = [ mock_list_bots.return_value = [
{"bot_id": "dev_central", "username": "dc_bot", "status": "running", "branch_name": "dev_central"}, {"bot_id": "dev_central", "username": "dc_bot", "status": "running", "branch_name": "dev_central"},
@@ -1204,14 +1204,14 @@ class TestStatusWithRegistry:
"message_id": 1, "message_id": 1,
}, },
} }
with patch("apps.handlers.base_bot.parse_command", return_value=("status", "")): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.parse_command", return_value=("status", "")):
with patch("apps.handlers.base_bot.build_status_text", return_value="Bot Status"): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.build_status_text", return_value="Bot Status"):
self.bot.process_update(update) self.bot.process_update(update)
msg = self.bot.send_message.call_args[0][1] msg = self.bot.send_message.call_args[0][1]
assert "Registered Bots" in msg assert "Registered Bots" in msg
assert "dc_bot" in msg assert "dc_bot" in msg
@patch("apps.handlers.base_bot.registry_list_bots", return_value=[]) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.registry_list_bots", return_value=[])
def test_status_empty_registry(self, mock_list_bots): def test_status_empty_registry(self, mock_list_bots):
update = { update = {
"update_id": 1, "update_id": 1,
@@ -1222,13 +1222,13 @@ class TestStatusWithRegistry:
"message_id": 1, "message_id": 1,
}, },
} }
with patch("apps.handlers.base_bot.parse_command", return_value=("status", "")): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.parse_command", return_value=("status", "")):
with patch("apps.handlers.base_bot.build_status_text", return_value="Bot Status"): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.build_status_text", return_value="Bot Status"):
self.bot.process_update(update) self.bot.process_update(update)
msg = self.bot.send_message.call_args[0][1] msg = self.bot.send_message.call_args[0][1]
assert "none" in msg.lower() assert "none" in msg.lower()
@patch("apps.handlers.base_bot.registry_list_bots") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.registry_list_bots")
def test_build_registry_status_with_bots(self, mock_list_bots): def test_build_registry_status_with_bots(self, mock_list_bots):
mock_list_bots.return_value = [ mock_list_bots.return_value = [
{"bot_id": "seed", "username": "seed_bot", "status": "running", "branch_name": "seed"}, {"bot_id": "seed", "username": "seed_bot", "status": "running", "branch_name": "seed"},
@@ -1239,13 +1239,13 @@ class TestStatusWithRegistry:
assert "seed_bot" in result assert "seed_bot" in result
assert "running" in result assert "running" in result
@patch("apps.handlers.base_bot.registry_list_bots") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.registry_list_bots")
def test_build_registry_status_exception(self, mock_list_bots): def test_build_registry_status_exception(self, mock_list_bots):
mock_list_bots.side_effect = RuntimeError("DB error") mock_list_bots.side_effect = RuntimeError("DB error")
result = self.bot._build_registry_status() result = self.bot._build_registry_status()
assert result == "" assert result == ""
@patch("apps.handlers.base_bot.registry_list_bots", return_value=[]) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.registry_list_bots", return_value=[])
def test_build_registry_status_empty(self, mock_list_bots): def test_build_registry_status_empty(self, mock_list_bots):
result = self.bot._build_registry_status() result = self.bot._build_registry_status()
assert result == "Registered Bots: none" assert result == "Registered Bots: none"
@@ -1261,7 +1261,7 @@ class TestGetCustomCommands:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_bot(self, tmp_path): def setup_bot(self, tmp_path):
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
self.bot = BaseBot( self.bot = BaseBot(
bot_id="test", bot_id="test",
bot_token="123:FAKE", bot_token="123:FAKE",
@@ -1291,7 +1291,7 @@ class TestCreateFlowInProcessUpdate:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_bot(self, tmp_path): def setup_bot(self, tmp_path):
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
self.bot = BaseBot( self.bot = BaseBot(
bot_id="test", bot_id="test",
bot_token="123:FAKE", bot_token="123:FAKE",
@@ -1313,9 +1313,9 @@ class TestCreateFlowInProcessUpdate:
}, },
} }
@patch("apps.handlers.base_bot.get_bot_by_branch", return_value=None) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.get_bot_by_branch", return_value=None)
@patch("apps.handlers.base_bot.validate_branch") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_branch")
@patch("apps.handlers.base_bot.parse_command", return_value=("create", "chat test_branch")) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.parse_command", return_value=("create", "chat test_branch"))
def test_process_update_create_command_routed(self, mock_parse, mock_validate, mock_get_bot): def test_process_update_create_command_routed(self, mock_parse, mock_validate, mock_get_bot):
"""Sending /create chat test_branch should call _handle_create_command.""" """Sending /create chat test_branch should call _handle_create_command."""
mock_validate.return_value = {"name": "test_branch", "path": "/tmp"} mock_validate.return_value = {"name": "test_branch", "path": "/tmp"}
@@ -1323,8 +1323,8 @@ class TestCreateFlowInProcessUpdate:
self.bot.process_update(self._make_update("/create chat test_branch")) self.bot.process_update(self._make_update("/create chat test_branch"))
mock_method.assert_called_once_with(self.chat_id, "chat test_branch") mock_method.assert_called_once_with(self.chat_id, "chat test_branch")
@patch("apps.handlers.base_bot.create_bot") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot")
@patch("apps.handlers.base_bot.validate_token") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_token")
def test_process_update_token_paste_during_create_flow(self, mock_validate_token, mock_create_bot): def test_process_update_token_paste_during_create_flow(self, mock_validate_token, mock_create_bot):
"""When _create_state is active, non-command text routes to _handle_create_token.""" """When _create_state is active, non-command text routes to _handle_create_token."""
self.bot._create_state[self.chat_id] = { self.bot._create_state[self.chat_id] = {
@@ -1341,7 +1341,7 @@ class TestCreateFlowInProcessUpdate:
def test_process_update_no_token_paste_without_state(self): def test_process_update_no_token_paste_without_state(self):
"""Without _create_state, non-command text routes to handle_message.""" """Without _create_state, non-command text routes to handle_message."""
with patch.object(self.bot, "handle_message") as mock_handle: with patch.object(self.bot, "handle_message") as mock_handle:
with patch("apps.handlers.base_bot.parse_command", return_value=None): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.parse_command", return_value=None):
self.bot.process_update(self._make_update("just regular text")) self.bot.process_update(self._make_update("just regular text"))
mock_handle.assert_called_once() mock_handle.assert_called_once()
@@ -1352,12 +1352,12 @@ class TestCreateFlowInProcessUpdate:
"branch_path": "/tmp", "branch_path": "/tmp",
"started_at": time.time(), "started_at": time.time(),
} }
with patch("apps.handlers.base_bot.parse_command", return_value=("cancel", "")): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.parse_command", return_value=("cancel", "")):
self.bot.process_update(self._make_update("/cancel")) self.bot.process_update(self._make_update("/cancel"))
assert self.chat_id not in self.bot._create_state assert self.chat_id not in self.bot._create_state
@patch("apps.handlers.base_bot.create_bot") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot")
@patch("apps.handlers.base_bot.validate_token") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_token")
def test_command_during_create_flow_not_treated_as_token(self, mock_validate_token, mock_create_bot): def test_command_during_create_flow_not_treated_as_token(self, mock_validate_token, mock_create_bot):
"""Even with _create_state active, /commands should not be routed to _handle_create_token.""" """Even with _create_state active, /commands should not be routed to _handle_create_token."""
self.bot._create_state[self.chat_id] = { self.bot._create_state[self.chat_id] = {
@@ -1365,7 +1365,7 @@ class TestCreateFlowInProcessUpdate:
"branch_path": "/tmp", "branch_path": "/tmp",
"started_at": time.time(), "started_at": time.time(),
} }
with patch("apps.handlers.base_bot.parse_command", return_value=("cancel", "")): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.parse_command", return_value=("cancel", "")):
with patch.object(self.bot, "_handle_create_token") as mock_token: with patch.object(self.bot, "_handle_create_token") as mock_token:
self.bot.process_update(self._make_update("/cancel")) self.bot.process_update(self._make_update("/cancel"))
mock_token.assert_not_called() mock_token.assert_not_called()
@@ -1381,7 +1381,7 @@ class TestCreateAutomated:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_bot(self, tmp_path): def setup_bot(self, tmp_path):
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
self.bot = BaseBot( self.bot = BaseBot(
bot_id="test", bot_id="test",
bot_token="123:FAKE", bot_token="123:FAKE",
@@ -1392,8 +1392,8 @@ class TestCreateAutomated:
self.bot.send_message = MagicMock(return_value={"message_id": 42}) self.bot.send_message = MagicMock(return_value={"message_id": 42})
self.chat_id = 12345 self.chat_id = 12345
@patch("apps.handlers.base_bot.create_bot") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot")
@patch("apps.handlers.base_bot.create_bot_via_botfather") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot_via_botfather")
def test_automated_flow_succeeds(self, mock_bf_create, mock_create_bot): def test_automated_flow_succeeds(self, mock_bf_create, mock_create_bot):
"""Automated flow: sends progress, calls BotFather, registers, sends success.""" """Automated flow: sends progress, calls BotFather, registers, sends success."""
mock_bf_create.return_value = { mock_bf_create.return_value = {
@@ -1423,7 +1423,7 @@ class TestCreateAutomated:
allowed_user_ids=[111], allowed_user_ids=[111],
) )
@patch("apps.handlers.base_bot.create_bot_via_botfather") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot_via_botfather")
def test_automated_flow_botfather_fails_falls_back_to_manual(self, mock_bf_create): def test_automated_flow_botfather_fails_falls_back_to_manual(self, mock_bf_create):
"""When BotFather automation fails, falls back to manual mode (sets _create_state).""" """When BotFather automation fails, falls back to manual mode (sets _create_state)."""
mock_bf_create.return_value = None mock_bf_create.return_value = None
@@ -1441,8 +1441,8 @@ class TestCreateAutomated:
assert state["branch_name"] == "flow" assert state["branch_name"] == "flow"
assert state["branch_path"] == "/home/aipass/flow" assert state["branch_path"] == "/home/aipass/flow"
@patch("apps.handlers.base_bot.create_bot", return_value=None) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot", return_value=None)
@patch("apps.handlers.base_bot.create_bot_via_botfather") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot_via_botfather")
def test_automated_flow_registration_fails(self, mock_bf_create, mock_create_bot): def test_automated_flow_registration_fails(self, mock_bf_create, mock_create_bot):
"""When BotFather succeeds but bot_factory.create_bot fails, sends error.""" """When BotFather succeeds but bot_factory.create_bot fails, sends error."""
mock_bf_create.return_value = { mock_bf_create.return_value = {
@@ -1460,9 +1460,9 @@ class TestCreateAutomated:
# Should NOT set _create_state (no manual fallback after registration failure) # Should NOT set _create_state (no manual fallback after registration failure)
assert self.chat_id not in self.bot._create_state assert self.chat_id not in self.bot._create_state
@patch("apps.handlers.base_bot.get_bot_by_branch", return_value=None) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.get_bot_by_branch", return_value=None)
@patch("apps.handlers.base_bot.validate_branch") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_branch")
@patch("apps.handlers.base_bot.check_telethon_setup") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.check_telethon_setup")
def test_create_command_uses_automated_when_telethon_ready(self, mock_check, mock_validate, mock_get_bot): def test_create_command_uses_automated_when_telethon_ready(self, mock_check, mock_validate, mock_get_bot):
"""_handle_create_command uses automated path when Telethon is ready.""" """_handle_create_command uses automated path when Telethon is ready."""
mock_check.return_value = (True, "ready") mock_check.return_value = (True, "ready")
@@ -1472,9 +1472,9 @@ class TestCreateAutomated:
self.bot._handle_create_command(self.chat_id, "chat dev_central") self.bot._handle_create_command(self.chat_id, "chat dev_central")
mock_automated.assert_called_once_with(self.chat_id, "dev_central", "/home/aipass/dev_central") mock_automated.assert_called_once_with(self.chat_id, "dev_central", "/home/aipass/dev_central")
@patch("apps.handlers.base_bot.get_bot_by_branch", return_value=None) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.get_bot_by_branch", return_value=None)
@patch("apps.handlers.base_bot.validate_branch") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_branch")
@patch("apps.handlers.base_bot.check_telethon_setup") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.check_telethon_setup")
def test_create_command_uses_manual_when_telethon_not_ready(self, mock_check, mock_validate, mock_get_bot): def test_create_command_uses_manual_when_telethon_not_ready(self, mock_check, mock_validate, mock_get_bot):
"""_handle_create_command uses manual path when Telethon is not ready.""" """_handle_create_command uses manual path when Telethon is not ready."""
mock_check.return_value = (False, "Telethon not installed") mock_check.return_value = (False, "Telethon not installed")
@@ -1489,9 +1489,9 @@ class TestCreateAutomated:
state = self.bot._create_state[self.chat_id] state = self.bot._create_state[self.chat_id]
assert state["branch_name"] == "flow" assert state["branch_name"] == "flow"
@patch("apps.handlers.base_bot.get_bot_by_branch", return_value=None) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.get_bot_by_branch", return_value=None)
@patch("apps.handlers.base_bot.validate_branch") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.validate_branch")
@patch("apps.handlers.base_bot.check_telethon_setup") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.check_telethon_setup")
def test_manual_fallback_message_shows_reason(self, mock_check, mock_validate, mock_get_bot): def test_manual_fallback_message_shows_reason(self, mock_check, mock_validate, mock_get_bot):
"""Manual fallback message includes the reason automation is unavailable.""" """Manual fallback message includes the reason automation is unavailable."""
mock_check.return_value = (False, "Telethon library not installed. Run: pip install telethon") mock_check.return_value = (False, "Telethon library not installed. Run: pip install telethon")
@@ -1503,8 +1503,8 @@ class TestCreateAutomated:
assert "Telethon library not installed" in msg assert "Telethon library not installed" in msg
assert "Falling back to manual token flow" in msg assert "Falling back to manual token flow" in msg
@patch("apps.handlers.base_bot.create_bot") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot")
@patch("apps.handlers.base_bot.create_bot_via_botfather") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot_via_botfather")
def test_automated_success_message_contains_service_info(self, mock_bf_create, mock_create_bot): def test_automated_success_message_contains_service_info(self, mock_bf_create, mock_create_bot):
"""Success message includes systemd service name and start command.""" """Success message includes systemd service name and start command."""
mock_bf_create.return_value = { mock_bf_create.return_value = {
@@ -1520,8 +1520,8 @@ class TestCreateAutomated:
assert "telegram-bot@memory_bank" in success_msg assert "telegram-bot@memory_bank" in success_msg
assert "systemctl" in success_msg assert "systemctl" in success_msg
@patch("apps.handlers.base_bot.create_bot") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot")
@patch("apps.handlers.base_bot.create_bot_via_botfather") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.create_bot_via_botfather")
def test_automated_flow_passes_allowed_user_ids(self, mock_bf_create, mock_create_bot): def test_automated_flow_passes_allowed_user_ids(self, mock_bf_create, mock_create_bot):
"""Automated flow passes the base bot's allowed_user_ids to create_bot.""" """Automated flow passes the base bot's allowed_user_ids to create_bot."""
mock_bf_create.return_value = { mock_bf_create.return_value = {
@@ -1551,7 +1551,7 @@ class TestSharedSession:
self.workdir = tmp_path / "workdir" self.workdir = tmp_path / "workdir"
self.workdir.mkdir() self.workdir.mkdir()
self.tmp_path = tmp_path self.tmp_path = tmp_path
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
self.bot = BaseBot( self.bot = BaseBot(
bot_id="dev_central", bot_id="dev_central",
bot_token="123:FAKETOKEN", bot_token="123:FAKETOKEN",
@@ -1571,8 +1571,8 @@ class TestSharedSession:
"""Default session_name is still telegram-{bot_id} until shared session found.""" """Default session_name is still telegram-{bot_id} until shared session found."""
assert self.bot.session_name == "telegram-dev_central" assert self.bot.session_name == "telegram-dev_central"
@patch("apps.handlers.base_bot.time.sleep") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.time.sleep")
@patch("apps.handlers.base_bot.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.subprocess.run")
def test_ensure_attaches_to_shared_session(self, mock_run, mock_sleep): def test_ensure_attaches_to_shared_session(self, mock_run, mock_sleep):
"""When shared session exists, bot attaches to it.""" """When shared session exists, bot attaches to it."""
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
@@ -1584,8 +1584,8 @@ class TestSharedSession:
mock_run.assert_called_once() mock_run.assert_called_once()
assert mock_run.call_args[0][0] == ["tmux", "has-session", "-t", "pc"] assert mock_run.call_args[0][0] == ["tmux", "has-session", "-t", "pc"]
@patch("apps.handlers.base_bot.time.sleep") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.time.sleep")
@patch("apps.handlers.base_bot.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.subprocess.run")
def test_ensure_returns_false_when_shared_missing(self, mock_run, mock_sleep): def test_ensure_returns_false_when_shared_missing(self, mock_run, mock_sleep):
"""When shared session doesn't exist and no presence, returns False (no spawn).""" """When shared session doesn't exist and no presence, returns False (no spawn)."""
mock_run.return_value = MagicMock(returncode=1) mock_run.return_value = MagicMock(returncode=1)
@@ -1596,7 +1596,7 @@ class TestSharedSession:
for call in calls: for call in calls:
assert "new-session" not in call assert "new-session" not in call
@patch("apps.handlers.base_bot.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.subprocess.run")
def test_inject_uses_shared_session_name(self, mock_run): def test_inject_uses_shared_session_name(self, mock_run):
"""After attaching, inject_message sends to the shared session.""" """After attaching, inject_message sends to the shared session."""
self.bot.session_name = "pc" self.bot.session_name = "pc"
@@ -1618,8 +1618,8 @@ class TestSharedSession:
assert self.bot._using_shared_session is False assert self.bot._using_shared_session is False
assert self.bot.session_name == "telegram-dev_central" assert self.bot.session_name == "telegram-dev_central"
@patch("apps.handlers.base_bot.time.sleep") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.time.sleep")
@patch("apps.handlers.base_bot.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.subprocess.run")
def test_pending_file_has_shared_session_name(self, mock_run, mock_sleep): def test_pending_file_has_shared_session_name(self, mock_run, mock_sleep):
"""Pending file session_name reflects the shared session.""" """Pending file session_name reflects the shared session."""
self.bot.session_name = "pc" self.bot.session_name = "pc"
@@ -1635,7 +1635,7 @@ class TestSharedSession:
"""Bot without shared_session behaves exactly as before.""" """Bot without shared_session behaves exactly as before."""
workdir = tmp_path / "normal" workdir = tmp_path / "normal"
workdir.mkdir() workdir.mkdir()
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BaseBot( bot = BaseBot(
bot_id="flow", bot_id="flow",
bot_token="456:FAKE", bot_token="456:FAKE",
@@ -1645,8 +1645,8 @@ class TestSharedSession:
assert bot._using_shared_session is False assert bot._using_shared_session is False
assert bot.session_name == "telegram-flow" assert bot.session_name == "telegram-flow"
@patch("apps.handlers.base_bot.time.sleep") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.time.sleep")
@patch("apps.handlers.base_bot.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.subprocess.run")
def test_reattaches_after_new_command(self, mock_run, mock_sleep): def test_reattaches_after_new_command(self, mock_run, mock_sleep):
"""After /new detaches, next ensure_tmux_session reattaches to shared session.""" """After /new detaches, next ensure_tmux_session reattaches to shared session."""
# Simulate attached to shared session # Simulate attached to shared session
@@ -1679,7 +1679,7 @@ class TestLockPidReuse:
self.workdir = tmp_path / "workdir" self.workdir = tmp_path / "workdir"
self.workdir.mkdir() self.workdir.mkdir()
self.tmp_path = tmp_path self.tmp_path = tmp_path
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
self.bot = BaseBot( self.bot = BaseBot(
bot_id="vera", bot_id="vera",
bot_token="123:FAKETOKEN", bot_token="123:FAKETOKEN",
@@ -1701,7 +1701,7 @@ class TestLockPidReuse:
assert self.bot._check_lock() is False assert self.bot._check_lock() is False
assert not self.bot._lock_file.exists() assert not self.bot._lock_file.exists()
@patch("apps.handlers.base_bot.os.kill") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.os.kill")
def test_alive_pid_same_bot_returns_true(self, mock_kill): def test_alive_pid_same_bot_returns_true(self, mock_kill):
"""Live PID running this bot returns True (lock held).""" """Live PID running this bot returns True (lock held)."""
mock_kill.return_value = None # PID alive mock_kill.return_value = None # PID alive
@@ -1716,7 +1716,7 @@ class TestLockPidReuse:
assert self.bot._check_lock() is True assert self.bot._check_lock() is True
assert self.bot._lock_file.exists() # Lock preserved assert self.bot._lock_file.exists() # Lock preserved
@patch("apps.handlers.base_bot.os.kill") @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.os.kill")
def test_alive_pid_different_bot_cleans_lock(self, mock_kill): def test_alive_pid_different_bot_cleans_lock(self, mock_kill):
"""Live PID running a DIFFERENT bot cleans stale lock (PID reuse).""" """Live PID running a DIFFERENT bot cleans stale lock (PID reuse)."""
mock_kill.return_value = None # PID alive mock_kill.return_value = None # PID alive
@@ -12,8 +12,8 @@ from unittest.mock import patch, MagicMock
import pytest import pytest
# Modules under test # Modules under test
from apps.handlers import config as tg_config from aipass.skills.lib.telegram.apps.handlers import config as tg_config
from apps.handlers.telegram_standards import ( from aipass.skills.lib.telegram.apps.handlers.telegram_standards import (
STANDARD_COMMANDS, STANDARD_COMMANDS,
PROCESSING_MSG, PROCESSING_MSG,
parse_command, parse_command,
@@ -23,7 +23,7 @@ from apps.handlers.telegram_standards import (
build_status_text, build_status_text,
build_botfather_commands, build_botfather_commands,
) )
from apps.handlers import bot_operations from aipass.skills.lib.telegram.apps.handlers import bot_operations
# ============================================= # =============================================
@@ -75,7 +75,7 @@ def sample_bots() -> list[dict]:
class TestLoadBotConfig: class TestLoadBotConfig:
"""Tests for config.load_bot_config (via _get_secret).""" """Tests for config.load_bot_config (via _get_secret)."""
@patch("apps.handlers.config._get_secret") @patch("aipass.skills.lib.telegram.apps.handlers.config._get_secret")
def test_load_valid_config(self, mock_get_secret: MagicMock, valid_bot_config: dict) -> None: def test_load_valid_config(self, mock_get_secret: MagicMock, valid_bot_config: dict) -> None:
"""Valid config returned from _get_secret loads correctly.""" """Valid config returned from _get_secret loads correctly."""
mock_get_secret.return_value = valid_bot_config mock_get_secret.return_value = valid_bot_config
@@ -88,7 +88,7 @@ class TestLoadBotConfig:
assert result["branch_name"] == "dev_central" assert result["branch_name"] == "dev_central"
mock_get_secret.assert_called_once_with("dev_central") mock_get_secret.assert_called_once_with("dev_central")
@patch("apps.handlers.config._get_secret") @patch("aipass.skills.lib.telegram.apps.handlers.config._get_secret")
def test_load_missing_file(self, mock_get_secret: MagicMock) -> None: def test_load_missing_file(self, mock_get_secret: MagicMock) -> None:
"""Returns None when secret not found.""" """Returns None when secret not found."""
mock_get_secret.return_value = None mock_get_secret.return_value = None
@@ -97,7 +97,7 @@ class TestLoadBotConfig:
assert result is None assert result is None
mock_get_secret.assert_called_once_with("nonexistent_bot") mock_get_secret.assert_called_once_with("nonexistent_bot")
@patch("apps.handlers.config._get_secret") @patch("aipass.skills.lib.telegram.apps.handlers.config._get_secret")
def test_load_corrupt_json(self, mock_get_secret: MagicMock) -> None: def test_load_corrupt_json(self, mock_get_secret: MagicMock) -> None:
"""Returns None when _get_secret returns None (e.g., invalid JSON from subprocess).""" """Returns None when _get_secret returns None (e.g., invalid JSON from subprocess)."""
mock_get_secret.return_value = None mock_get_secret.return_value = None
@@ -105,7 +105,7 @@ class TestLoadBotConfig:
result = tg_config.load_bot_config("broken") result = tg_config.load_bot_config("broken")
assert result is None assert result is None
@patch("apps.handlers.config._get_secret") @patch("aipass.skills.lib.telegram.apps.handlers.config._get_secret")
def test_load_non_dict_json(self, mock_get_secret: MagicMock) -> None: def test_load_non_dict_json(self, mock_get_secret: MagicMock) -> None:
"""Returns None when _get_secret returns None (non-dict JSON is filtered by _get_secret).""" """Returns None when _get_secret returns None (non-dict JSON is filtered by _get_secret)."""
# _get_secret already filters non-dict responses and returns None # _get_secret already filters non-dict responses and returns None
@@ -123,7 +123,7 @@ class TestLoadBotConfig:
class TestListBotConfigs: class TestListBotConfigs:
"""Tests for config.list_bot_configs (via in-process secrets API).""" """Tests for config.list_bot_configs (via in-process secrets API)."""
@patch("apps.handlers.config._api_list_secrets") @patch("aipass.skills.lib.telegram.apps.handlers.config._api_list_secrets")
def test_list_returns_bot_ids(self, mock_list: MagicMock) -> None: def test_list_returns_bot_ids(self, mock_list: MagicMock) -> None:
"""Returns list of bot_ids from the secrets API.""" """Returns list of bot_ids from the secrets API."""
mock_list.return_value = ["dev_central", "assistant", "scheduler"] mock_list.return_value = ["dev_central", "assistant", "scheduler"]
@@ -136,7 +136,7 @@ class TestListBotConfigs:
assert len(result) == 3 assert len(result) == 3
mock_list.assert_called_once_with("telegram") mock_list.assert_called_once_with("telegram")
@patch("apps.handlers.config._api_list_secrets") @patch("aipass.skills.lib.telegram.apps.handlers.config._api_list_secrets")
def test_list_returns_empty_on_failure(self, mock_list: MagicMock) -> None: def test_list_returns_empty_on_failure(self, mock_list: MagicMock) -> None:
"""Returns empty list when the secrets API raises.""" """Returns empty list when the secrets API raises."""
mock_list.side_effect = RuntimeError("connection failed") mock_list.side_effect = RuntimeError("connection failed")
@@ -144,7 +144,7 @@ class TestListBotConfigs:
result = tg_config.list_bot_configs() result = tg_config.list_bot_configs()
assert result == [] assert result == []
@patch("apps.handlers.config._api_list_secrets") @patch("aipass.skills.lib.telegram.apps.handlers.config._api_list_secrets")
def test_list_returns_empty_when_no_secrets(self, mock_list: MagicMock) -> None: def test_list_returns_empty_when_no_secrets(self, mock_list: MagicMock) -> None:
"""Returns empty list when no secrets exist.""" """Returns empty list when no secrets exist."""
mock_list.return_value = [] mock_list.return_value = []
@@ -152,7 +152,7 @@ class TestListBotConfigs:
result = tg_config.list_bot_configs() result = tg_config.list_bot_configs()
assert result == [] assert result == []
@patch("apps.handlers.config._api_list_secrets") @patch("aipass.skills.lib.telegram.apps.handlers.config._api_list_secrets")
def test_list_returns_empty_on_unexpected_error(self, mock_list: MagicMock) -> None: def test_list_returns_empty_on_unexpected_error(self, mock_list: MagicMock) -> None:
"""Returns empty list on unexpected exception.""" """Returns empty list on unexpected exception."""
mock_list.side_effect = OSError("disk error") mock_list.side_effect = OSError("disk error")
@@ -352,7 +352,7 @@ class TestHandleStandardCommand:
assert "@assistant" in result[1] assert "@assistant" in result[1]
assert "fresh" in result[1].lower() or "cleared" in result[1].lower() assert "fresh" in result[1].lower() or "cleared" in result[1].lower()
@patch("apps.handlers.telegram_standards._tmux_session_exists") @patch("aipass.skills.lib.telegram.apps.handlers.telegram_standards._tmux_session_exists")
def test_status_returns_status_text(self, mock_tmux: MagicMock) -> None: def test_status_returns_status_text(self, mock_tmux: MagicMock) -> None:
"""The 'status' command returns status text string.""" """The 'status' command returns status text string."""
mock_tmux.return_value = True mock_tmux.return_value = True
@@ -476,7 +476,7 @@ class TestBuildWelcomeText:
class TestBuildStatusText: class TestBuildStatusText:
"""Tests for telegram_standards.build_status_text.""" """Tests for telegram_standards.build_status_text."""
@patch("apps.handlers.telegram_standards._tmux_session_exists") @patch("aipass.skills.lib.telegram.apps.handlers.telegram_standards._tmux_session_exists")
def test_active_session(self, mock_tmux: MagicMock) -> None: def test_active_session(self, mock_tmux: MagicMock) -> None:
"""Active tmux session shows 'Active' state.""" """Active tmux session shows 'Active' state."""
mock_tmux.return_value = True mock_tmux.return_value = True
@@ -488,7 +488,7 @@ class TestBuildStatusText:
assert "@dev_central" in result assert "@dev_central" in result
assert "telegram-dev_central" in result assert "telegram-dev_central" in result
@patch("apps.handlers.telegram_standards._tmux_session_exists") @patch("aipass.skills.lib.telegram.apps.handlers.telegram_standards._tmux_session_exists")
def test_inactive_session(self, mock_tmux: MagicMock) -> None: def test_inactive_session(self, mock_tmux: MagicMock) -> None:
"""Inactive tmux session shows 'Inactive' state.""" """Inactive tmux session shows 'Inactive' state."""
mock_tmux.return_value = False mock_tmux.return_value = False
@@ -498,7 +498,7 @@ class TestBuildStatusText:
) )
assert "Inactive" in result assert "Inactive" in result
@patch("apps.handlers.telegram_standards._tmux_session_exists") @patch("aipass.skills.lib.telegram.apps.handlers.telegram_standards._tmux_session_exists")
def test_optional_fields_included(self, mock_tmux: MagicMock) -> None: def test_optional_fields_included(self, mock_tmux: MagicMock) -> None:
"""Optional fields (uptime, message_count, chat_id) appear when provided.""" """Optional fields (uptime, message_count, chat_id) appear when provided."""
mock_tmux.return_value = True mock_tmux.return_value = True
@@ -513,7 +513,7 @@ class TestBuildStatusText:
assert "Messages: 99" in result assert "Messages: 99" in result
assert "Chat ID: 12345" in result assert "Chat ID: 12345" in result
@patch("apps.handlers.telegram_standards._tmux_session_exists") @patch("aipass.skills.lib.telegram.apps.handlers.telegram_standards._tmux_session_exists")
def test_optional_fields_omitted(self, mock_tmux: MagicMock) -> None: def test_optional_fields_omitted(self, mock_tmux: MagicMock) -> None:
"""Optional fields are not shown when not provided.""" """Optional fields are not shown when not provided."""
mock_tmux.return_value = True mock_tmux.return_value = True
@@ -753,7 +753,7 @@ class TestFormatBotTable:
class TestGetStatusAndGetAllBots: class TestGetStatusAndGetAllBots:
"""Tests for bot_operations.get_status and get_all_bots.""" """Tests for bot_operations.get_status and get_all_bots."""
@patch("apps.handlers.bot_operations.get_bot") @patch("aipass.skills.lib.telegram.apps.handlers.bot_operations.get_bot")
def test_get_status_specific_bot(self, mock_get_bot: MagicMock) -> None: def test_get_status_specific_bot(self, mock_get_bot: MagicMock) -> None:
"""get_status with bot_id delegates to get_bot.""" """get_status with bot_id delegates to get_bot."""
mock_get_bot.return_value = {"bot_id": "dev_central", "status": "active"} mock_get_bot.return_value = {"bot_id": "dev_central", "status": "active"}
@@ -763,7 +763,7 @@ class TestGetStatusAndGetAllBots:
assert result[0]["bot_id"] == "dev_central" assert result[0]["bot_id"] == "dev_central"
mock_get_bot.assert_called_once_with("dev_central") mock_get_bot.assert_called_once_with("dev_central")
@patch("apps.handlers.bot_operations.get_bot") @patch("aipass.skills.lib.telegram.apps.handlers.bot_operations.get_bot")
def test_get_status_bot_not_found(self, mock_get_bot: MagicMock) -> None: def test_get_status_bot_not_found(self, mock_get_bot: MagicMock) -> None:
"""get_status returns empty list when bot not found.""" """get_status returns empty list when bot not found."""
mock_get_bot.return_value = None mock_get_bot.return_value = None
@@ -771,7 +771,7 @@ class TestGetStatusAndGetAllBots:
result = bot_operations.get_status("nonexistent") result = bot_operations.get_status("nonexistent")
assert result == [] assert result == []
@patch("apps.handlers.bot_operations.list_bots") @patch("aipass.skills.lib.telegram.apps.handlers.bot_operations.list_bots")
def test_get_status_all_bots(self, mock_list_bots: MagicMock) -> None: def test_get_status_all_bots(self, mock_list_bots: MagicMock) -> None:
"""get_status with no bot_id delegates to list_bots.""" """get_status with no bot_id delegates to list_bots."""
mock_list_bots.return_value = [ mock_list_bots.return_value = [
@@ -783,7 +783,7 @@ class TestGetStatusAndGetAllBots:
assert len(result) == 2 assert len(result) == 2
mock_list_bots.assert_called_once() mock_list_bots.assert_called_once()
@patch("apps.handlers.bot_operations.list_bots") @patch("aipass.skills.lib.telegram.apps.handlers.bot_operations.list_bots")
def test_get_all_bots(self, mock_list_bots: MagicMock) -> None: def test_get_all_bots(self, mock_list_bots: MagicMock) -> None:
"""get_all_bots delegates to list_bots.""" """get_all_bots delegates to list_bots."""
expected = [{"bot_id": "x"}, {"bot_id": "y"}] expected = [{"bot_id": "x"}, {"bot_id": "y"}]
@@ -802,11 +802,11 @@ class TestGetStatusAndGetAllBots:
class TestCreateBotRoundTrip: class TestCreateBotRoundTrip:
"""Prove GAP1 is closed: create_bot persists config that load_bot_config reads.""" """Prove GAP1 is closed: create_bot persists config that load_bot_config reads."""
@patch("apps.handlers.bot_factory.start_bot_process", return_value=True) @patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.start_bot_process", return_value=True)
@patch("apps.handlers.bot_factory.enable_service", return_value=True) @patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.enable_service", return_value=True)
@patch("apps.handlers.bot_factory.set_bot_commands", return_value=True) @patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.set_bot_commands", return_value=True)
@patch("apps.handlers.bot_factory.validate_token") @patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.validate_token")
@patch("apps.handlers.bot_factory.ensure_registry") @patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.ensure_registry")
def test_create_then_load_roundtrip( def test_create_then_load_roundtrip(
self, self,
mock_ensure_registry, mock_ensure_registry,
@@ -818,7 +818,7 @@ class TestCreateBotRoundTrip:
monkeypatch, monkeypatch,
): ):
"""After create_bot, load_bot_config returns the persisted config.""" """After create_bot, load_bot_config returns the persisted config."""
from apps.handlers import bot_factory, config as tg_config from aipass.skills.lib.telegram.apps.handlers import bot_factory, config as tg_config
mock_validate_token.return_value = {"username": "test_bot", "id": 123} mock_validate_token.return_value = {"username": "test_bot", "id": 123}
@@ -836,9 +836,9 @@ class TestCreateBotRoundTrip:
monkeypatch.setattr(bot_factory, "_api_set_secret", fake_set_secret) monkeypatch.setattr(bot_factory, "_api_set_secret", fake_set_secret)
monkeypatch.setattr(tg_config, "_api_get_secret", fake_get_secret) monkeypatch.setattr(tg_config, "_api_get_secret", fake_get_secret)
monkeypatch.setattr("apps.handlers.bot_registry.REGISTRY_DIR", tmp_path / "state") monkeypatch.setattr("aipass.skills.lib.telegram.apps.handlers.bot_registry.REGISTRY_DIR", tmp_path / "state")
monkeypatch.setattr( monkeypatch.setattr(
"apps.handlers.bot_registry.REGISTRY_FILE", "aipass.skills.lib.telegram.apps.handlers.bot_registry.REGISTRY_FILE",
tmp_path / "state" / "_registry.json", tmp_path / "state" / "_registry.json",
) )
@@ -857,8 +857,8 @@ class TestCreateBotRoundTrip:
assert loaded["bot_token"] == "111:AAA-test-token" assert loaded["bot_token"] == "111:AAA-test-token"
assert loaded["allowed_user_ids"] == [42] assert loaded["allowed_user_ids"] == [42]
@patch("apps.handlers.bot_factory.validate_token") @patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.validate_token")
@patch("apps.handlers.bot_factory.ensure_registry") @patch("aipass.skills.lib.telegram.apps.handlers.bot_factory.ensure_registry")
def test_create_fails_loud_on_set_secret_error( def test_create_fails_loud_on_set_secret_error(
self, self,
mock_ensure_registry, mock_ensure_registry,
@@ -867,7 +867,7 @@ class TestCreateBotRoundTrip:
monkeypatch, monkeypatch,
): ):
"""create_bot returns None and logs error if set_secret raises.""" """create_bot returns None and logs error if set_secret raises."""
from apps.handlers import bot_factory from aipass.skills.lib.telegram.apps.handlers import bot_factory
mock_validate_token.return_value = {"username": "test_bot", "id": 123} mock_validate_token.return_value = {"username": "test_bot", "id": 123}
monkeypatch.setattr(bot_factory, "_BOT_CONFIG_DIR", tmp_path) monkeypatch.setattr(bot_factory, "_BOT_CONFIG_DIR", tmp_path)
@@ -894,11 +894,11 @@ class TestCommandMenuSync:
def test_menu_and_help_have_same_commands(self): def test_menu_and_help_have_same_commands(self):
"""The command names in build_botfather_commands match those in build_help_text.""" """The command names in build_botfather_commands match those in build_help_text."""
from apps.handlers.telegram_standards import ( from aipass.skills.lib.telegram.apps.handlers.telegram_standards import (
build_botfather_commands, build_botfather_commands,
build_help_text, build_help_text,
) )
from apps.handlers.base_bot import BaseBot from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
from unittest.mock import MagicMock from unittest.mock import MagicMock
mock_self = MagicMock(spec=BaseBot) mock_self = MagicMock(spec=BaseBot)
@@ -918,8 +918,8 @@ class TestCommandMenuSync:
def test_help_contains_enriched_descriptions(self): def test_help_contains_enriched_descriptions(self):
"""The /help text includes the enriched descriptions.""" """The /help text includes the enriched descriptions."""
from apps.handlers.telegram_standards import build_help_text from aipass.skills.lib.telegram.apps.handlers.telegram_standards import build_help_text
from apps.handlers.base_bot import BaseBot from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
from unittest.mock import MagicMock from unittest.mock import MagicMock
mock_self = MagicMock(spec=BaseBot) mock_self = MagicMock(spec=BaseBot)
@@ -936,15 +936,15 @@ class TestCommandMenuSync:
def test_help_footer_updated(self): def test_help_footer_updated(self):
"""The /help footer uses the enriched text.""" """The /help footer uses the enriched text."""
from apps.handlers.telegram_standards import build_help_text from aipass.skills.lib.telegram.apps.handlers.telegram_standards import build_help_text
help_text = build_help_text() help_text = build_help_text()
assert "Just send any message to talk to me" in help_text assert "Just send any message to talk to me" in help_text
def test_create_bot_uses_single_source(self): def test_create_bot_uses_single_source(self):
"""create_bot calls set_bot_commands with build_botfather_commands output.""" """create_bot calls set_bot_commands with build_botfather_commands output."""
from apps.handlers import bot_factory from aipass.skills.lib.telegram.apps.handlers import bot_factory
from apps.handlers.telegram_standards import build_botfather_commands from aipass.skills.lib.telegram.apps.handlers.telegram_standards import build_botfather_commands
expected = build_botfather_commands() expected = build_botfather_commands()
with patch.object(bot_factory, "set_bot_commands") as mock_set: with patch.object(bot_factory, "set_bot_commands") as mock_set:
@@ -963,11 +963,11 @@ class TestCommandMenuSync:
class TestBaseBotStartupMenu: class TestBaseBotStartupMenu:
"""Verify base_bot sets command menu on startup.""" """Verify base_bot sets command menu on startup."""
@patch("apps.handlers.base_bot.set_bot_commands", return_value=True) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.set_bot_commands", return_value=True)
def test_set_command_menu_called_on_startup(self, mock_set_commands): def test_set_command_menu_called_on_startup(self, mock_set_commands):
"""_set_command_menu calls set_bot_commands with merged commands.""" """_set_command_menu calls set_bot_commands with merged commands."""
from apps.handlers.base_bot import BaseBot from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
from apps.handlers.telegram_standards import build_botfather_commands from aipass.skills.lib.telegram.apps.handlers.telegram_standards import build_botfather_commands
bot = BaseBot.__new__(BaseBot) bot = BaseBot.__new__(BaseBot)
bot.bot_token = "123:ABC" bot.bot_token = "123:ABC"
@@ -981,10 +981,10 @@ class TestBaseBotStartupMenu:
expected = build_botfather_commands(custom_commands=bot.get_custom_commands()) expected = build_botfather_commands(custom_commands=bot.get_custom_commands())
assert actual_commands == expected assert actual_commands == expected
@patch("apps.handlers.base_bot.set_bot_commands", return_value=True) @patch("aipass.skills.lib.telegram.apps.handlers.base_bot.set_bot_commands", return_value=True)
def test_menu_includes_custom_commands(self, mock_set_commands): def test_menu_includes_custom_commands(self, mock_set_commands):
"""Menu includes /create and /cancel from get_custom_commands.""" """Menu includes /create and /cancel from get_custom_commands."""
from apps.handlers.base_bot import BaseBot from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
bot = BaseBot.__new__(BaseBot) bot = BaseBot.__new__(BaseBot)
bot.bot_token = "123:ABC" bot.bot_token = "123:ABC"
@@ -13,7 +13,7 @@ import time
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from apps.handlers import tmux_manager as tg_tmux from aipass.skills.lib.telegram.apps.handlers import tmux_manager as tg_tmux
try: try:
from aipass.hooks.apps.handlers.notification import telegram_response as tg_hook from aipass.hooks.apps.handlers.notification import telegram_response as tg_hook
@@ -67,7 +67,7 @@ def _make_subprocess_result(returncode=0, stdout="", stderr=""):
class TestTmuxSessionExists: class TestTmuxSessionExists:
"""Tests for tmux_manager.session_exists.""" """Tests for tmux_manager.session_exists."""
@patch("apps.handlers.tmux_manager.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.tmux_manager.subprocess.run")
def test_session_exists_returns_true(self, mock_run): def test_session_exists_returns_true(self, mock_run):
"""session_exists returns True when tmux has-session succeeds.""" """session_exists returns True when tmux has-session succeeds."""
mock_run.return_value = _make_subprocess_result(returncode=0) mock_run.return_value = _make_subprocess_result(returncode=0)
@@ -78,7 +78,7 @@ class TestTmuxSessionExists:
capture_output=True, capture_output=True,
) )
@patch("apps.handlers.tmux_manager.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.tmux_manager.subprocess.run")
def test_session_exists_returns_false(self, mock_run): def test_session_exists_returns_false(self, mock_run):
"""session_exists returns False when tmux has-session fails.""" """session_exists returns False when tmux has-session fails."""
mock_run.return_value = _make_subprocess_result(returncode=1) mock_run.return_value = _make_subprocess_result(returncode=1)
@@ -89,7 +89,7 @@ class TestTmuxSessionExists:
class TestTmuxKillSession: class TestTmuxKillSession:
"""Tests for tmux_manager.kill_session.""" """Tests for tmux_manager.kill_session."""
@patch("apps.handlers.tmux_manager.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.tmux_manager.subprocess.run")
def test_kill_session_returns_true(self, mock_run): def test_kill_session_returns_true(self, mock_run):
"""kill_session returns True when session exists and is killed.""" """kill_session returns True when session exists and is killed."""
mock_run.side_effect = [ mock_run.side_effect = [
@@ -101,7 +101,7 @@ class TestTmuxKillSession:
kill_call = mock_run.call_args_list[1] kill_call = mock_run.call_args_list[1]
assert kill_call[0][0] == ["tmux", "kill-session", "-t", "telegram-dev_central"] assert kill_call[0][0] == ["tmux", "kill-session", "-t", "telegram-dev_central"]
@patch("apps.handlers.tmux_manager.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.tmux_manager.subprocess.run")
def test_kill_session_returns_true_when_not_exists(self, mock_run): def test_kill_session_returns_true_when_not_exists(self, mock_run):
"""kill_session returns True when session doesn't exist (nothing to kill).""" """kill_session returns True when session doesn't exist (nothing to kill)."""
mock_run.return_value = _make_subprocess_result(returncode=1) # has-session: not found mock_run.return_value = _make_subprocess_result(returncode=1) # has-session: not found
@@ -112,7 +112,7 @@ class TestTmuxKillSession:
class TestTmuxListSessions: class TestTmuxListSessions:
"""Tests for tmux_manager.list_sessions.""" """Tests for tmux_manager.list_sessions."""
@patch("apps.handlers.tmux_manager.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.tmux_manager.subprocess.run")
def test_list_sessions_filters_telegram_prefix(self, mock_run): def test_list_sessions_filters_telegram_prefix(self, mock_run):
"""list_sessions returns only branch names from telegram-* sessions.""" """list_sessions returns only branch names from telegram-* sessions."""
mock_run.return_value = _make_subprocess_result( mock_run.return_value = _make_subprocess_result(
@@ -123,7 +123,7 @@ class TestTmuxListSessions:
assert result == ["dev_central", "flow"] assert result == ["dev_central", "flow"]
@patch("apps.handlers.tmux_manager.subprocess.run") @patch("aipass.skills.lib.telegram.apps.handlers.tmux_manager.subprocess.run")
def test_list_sessions_returns_empty_on_failure(self, mock_run): def test_list_sessions_returns_empty_on_failure(self, mock_run):
"""list_sessions returns [] when tmux command fails.""" """list_sessions returns [] when tmux command fails."""
mock_run.return_value = _make_subprocess_result(returncode=1) mock_run.return_value = _make_subprocess_result(returncode=1)
@@ -15,15 +15,15 @@ Tests cover:
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
@pytest.fixture @pytest.fixture
def _patch_base_bot_deps(tmp_path): def _patch_base_bot_deps(tmp_path):
patches = [ patches = [
patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path),
patch("apps.handlers.base_bot.signal.signal"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.signal.signal"),
patch("apps.handlers.base_bot.atexit.register"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.atexit.register"),
] ]
for p in patches: for p in patches:
p.start() p.start()
@@ -26,7 +26,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
# ============================================= # =============================================
@@ -38,9 +38,9 @@ from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found]
def _patch_base_bot_deps(tmp_path): def _patch_base_bot_deps(tmp_path):
"""Patch signal and atexit for safe BaseBot construction.""" """Patch signal and atexit for safe BaseBot construction."""
patches = [ patches = [
patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path),
patch("apps.handlers.base_bot.signal.signal"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.signal.signal"),
patch("apps.handlers.base_bot.atexit.register"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.atexit.register"),
] ]
for p in patches: for p in patches:
p.start() p.start()
@@ -53,7 +53,7 @@ def _make_bot(tmp_path, _patch_base_bot_deps, branch_name="devpulse"):
"""Create a BaseBot with test defaults.""" """Create a BaseBot with test defaults."""
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir(exist_ok=True) workdir.mkdir(exist_ok=True)
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BaseBot( bot = BaseBot(
bot_id="cc_test", bot_id="cc_test",
bot_token="123:FAKETOKEN", bot_token="123:FAKETOKEN",
@@ -106,7 +106,7 @@ class TestDiscoverCcSession:
bot = _make_bot(tmp_path, _patch_base_bot_deps) bot = _make_bot(tmp_path, _patch_base_bot_deps)
sessions_dir = tmp_path / "sessions" sessions_dir = tmp_path / "sessions"
_write_cc_session(sessions_dir, os.getpid(), str(bot.work_dir.resolve())) _write_cc_session(sessions_dir, os.getpid(), str(bot.work_dir.resolve()))
with patch("apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir):
result = bot._discover_cc_session() result = bot._discover_cc_session()
assert result is not None assert result is not None
assert result["pid"] == os.getpid() assert result["pid"] == os.getpid()
@@ -116,7 +116,7 @@ class TestDiscoverCcSession:
"""Returns None when the sessions directory does not exist.""" """Returns None when the sessions directory does not exist."""
bot = _make_bot(tmp_path, _patch_base_bot_deps) bot = _make_bot(tmp_path, _patch_base_bot_deps)
fake_dir = tmp_path / "no_such_dir" fake_dir = tmp_path / "no_such_dir"
with patch("apps.handlers.base_bot.CC_SESSIONS_DIR", fake_dir): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.CC_SESSIONS_DIR", fake_dir):
result = bot._discover_cc_session() result = bot._discover_cc_session()
assert result is None assert result is None
@@ -125,7 +125,7 @@ class TestDiscoverCcSession:
bot = _make_bot(tmp_path, _patch_base_bot_deps) bot = _make_bot(tmp_path, _patch_base_bot_deps)
sessions_dir = tmp_path / "sessions" sessions_dir = tmp_path / "sessions"
_write_cc_session(sessions_dir, os.getpid(), "/some/other/dir") _write_cc_session(sessions_dir, os.getpid(), "/some/other/dir")
with patch("apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir):
result = bot._discover_cc_session() result = bot._discover_cc_session()
assert result is None assert result is None
@@ -134,7 +134,7 @@ class TestDiscoverCcSession:
bot = _make_bot(tmp_path, _patch_base_bot_deps) bot = _make_bot(tmp_path, _patch_base_bot_deps)
sessions_dir = tmp_path / "sessions" sessions_dir = tmp_path / "sessions"
_write_cc_session(sessions_dir, 99999999, str(bot.work_dir.resolve())) _write_cc_session(sessions_dir, 99999999, str(bot.work_dir.resolve()))
with patch("apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir):
result = bot._discover_cc_session() result = bot._discover_cc_session()
assert result is None assert result is None
@@ -147,7 +147,7 @@ class TestDiscoverCcSession:
_write_cc_session(sessions_dir, my_pid, cwd, session_id="old", started_at=100) _write_cc_session(sessions_dir, my_pid, cwd, session_id="old", started_at=100)
_write_cc_session(sessions_dir, my_pid + 1, cwd, session_id="new", started_at=200) _write_cc_session(sessions_dir, my_pid + 1, cwd, session_id="new", started_at=200)
with ( with (
patch("apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir),
patch.object(BaseBot, "_is_pid_alive", return_value=True), patch.object(BaseBot, "_is_pid_alive", return_value=True),
): ):
result = bot._discover_cc_session() result = bot._discover_cc_session()
@@ -161,7 +161,7 @@ class TestDiscoverCcSession:
sessions_dir.mkdir(parents=True) sessions_dir.mkdir(parents=True)
(sessions_dir / "readme.txt").write_text("ignore me") (sessions_dir / "readme.txt").write_text("ignore me")
(sessions_dir / "notapid.json").write_text("{}") (sessions_dir / "notapid.json").write_text("{}")
with patch("apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir):
result = bot._discover_cc_session() result = bot._discover_cc_session()
assert result is None assert result is None
@@ -171,7 +171,7 @@ class TestDiscoverCcSession:
sessions_dir = tmp_path / "sessions" sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir(parents=True) sessions_dir.mkdir(parents=True)
(sessions_dir / "12345.json").write_text("not json!") (sessions_dir / "12345.json").write_text("not json!")
with patch("apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir):
result = bot._discover_cc_session() result = bot._discover_cc_session()
assert result is None assert result is None
@@ -181,7 +181,7 @@ class TestDiscoverCcSession:
real_dir.mkdir() real_dir.mkdir()
link = tmp_path / "link_workdir" link = tmp_path / "link_workdir"
link.symlink_to(real_dir) link.symlink_to(real_dir)
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BaseBot( bot = BaseBot(
bot_id="sym_test", bot_id="sym_test",
bot_token="t", bot_token="t",
@@ -190,7 +190,7 @@ class TestDiscoverCcSession:
) )
sessions_dir = tmp_path / "sessions" sessions_dir = tmp_path / "sessions"
_write_cc_session(sessions_dir, os.getpid(), str(real_dir)) _write_cc_session(sessions_dir, os.getpid(), str(real_dir))
with patch("apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.CC_SESSIONS_DIR", sessions_dir):
result = bot._discover_cc_session() result = bot._discover_cc_session()
assert result is not None assert result is not None
@@ -438,7 +438,7 @@ class TestEnsureWithCcDiscovery:
"""CC session found but no tmux pane → falls through to Strategy 2.""" """CC session found but no tmux pane → falls through to Strategy 2."""
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir(exist_ok=True) workdir.mkdir(exist_ok=True)
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BaseBot( bot = BaseBot(
bot_id="fb_test", bot_id="fb_test",
bot_token="t", bot_token="t",
@@ -461,7 +461,7 @@ class TestEnsureWithCcDiscovery:
"""When CC discovery returns None, falls back to shared_session config.""" """When CC discovery returns None, falls back to shared_session config."""
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir(exist_ok=True) workdir.mkdir(exist_ok=True)
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BaseBot( bot = BaseBot(
bot_id="fb_test", bot_id="fb_test",
bot_token="t", bot_token="t",
@@ -25,7 +25,7 @@ from pathlib import Path
import pytest import pytest
from unittest.mock import MagicMock from unittest.mock import MagicMock
import apps.handlers.response_router as response_router # type: ignore[import-not-found] import aipass.skills.lib.telegram.apps.handlers.response_router as response_router
# ============================================= # =============================================
@@ -15,7 +15,7 @@ import json
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from apps.handlers.scheduler_bot import SchedulerBot, QUEUE_CMD # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.scheduler_bot import SchedulerBot, QUEUE_CMD
# ============================================= # =============================================
@@ -63,9 +63,9 @@ EMPTY_QUEUE = {"generated_at": "2026-06-25T15:00:00Z", "count": 0, "jobs": []}
def _patch_base_bot_deps(tmp_path): def _patch_base_bot_deps(tmp_path):
"""Patch heavy BaseBot dependencies to allow lightweight instantiation.""" """Patch heavy BaseBot dependencies to allow lightweight instantiation."""
patches = [ patches = [
patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path),
patch("apps.handlers.base_bot.signal.signal"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.signal.signal"),
patch("apps.handlers.base_bot.atexit.register"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.atexit.register"),
] ]
for p in patches: for p in patches:
p.start() p.start()
@@ -148,7 +148,7 @@ class TestQueueCommand:
mock_result.returncode = 0 mock_result.returncode = 0
mock_result.stdout = json.dumps(SAMPLE_QUEUE) mock_result.stdout = json.dumps(SAMPLE_QUEUE)
with patch("apps.handlers.scheduler_bot.subprocess.run", return_value=mock_result): with patch("aipass.skills.lib.telegram.apps.handlers.scheduler_bot.subprocess.run", return_value=mock_result):
data = bot._fetch_queue() data = bot._fetch_queue()
assert data is not None assert data is not None
@@ -161,7 +161,7 @@ class TestQueueCommand:
mock_result.returncode = 1 mock_result.returncode = 1
mock_result.stderr = "error" mock_result.stderr = "error"
with patch("apps.handlers.scheduler_bot.subprocess.run", return_value=mock_result): with patch("aipass.skills.lib.telegram.apps.handlers.scheduler_bot.subprocess.run", return_value=mock_result):
data = bot._fetch_queue() data = bot._fetch_queue()
assert data is None assert data is None
@@ -170,7 +170,7 @@ class TestQueueCommand:
bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps)
import subprocess as sp import subprocess as sp
with patch("apps.handlers.scheduler_bot.subprocess.run", side_effect=sp.TimeoutExpired(QUEUE_CMD, 15)): with patch("aipass.skills.lib.telegram.apps.handlers.scheduler_bot.subprocess.run", side_effect=sp.TimeoutExpired(QUEUE_CMD, 15)):
data = bot._fetch_queue() data = bot._fetch_queue()
assert data is None assert data is None
@@ -199,7 +199,7 @@ class TestNoTmux:
bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps)
with ( with (
patch.object(bot, "send_message") as mock_send, patch.object(bot, "send_message") as mock_send,
patch("apps.handlers.base_bot.BaseBot.handle_file") as mock_parent_file, patch("aipass.skills.lib.telegram.apps.handlers.base_bot.BaseBot.handle_file") as mock_parent_file,
): ):
bot.handle_file(42, {"message_id": 1, "document": {"file_id": "abc"}}) bot.handle_file(42, {"message_id": 1, "document": {"file_id": "abc"}})
mock_parent_file.assert_not_called() mock_parent_file.assert_not_called()
@@ -325,9 +325,9 @@ class TestSecretLoading:
def test_missing_secret_returns_none(self): def test_missing_secret_returns_none(self):
"""When get_secret returns None, load_bot_config returns None — bot won't start.""" """When get_secret returns None, load_bot_config returns None — bot won't start."""
from apps.handlers.config import load_bot_config # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.config import load_bot_config
with patch("apps.handlers.config._get_secret", return_value=None): with patch("aipass.skills.lib.telegram.apps.handlers.config._get_secret", return_value=None):
config = load_bot_config("scheduler") config = load_bot_config("scheduler")
assert config is None assert config is None
@@ -22,16 +22,16 @@ import time
import pytest import pytest
from unittest.mock import patch from unittest.mock import patch
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
from apps.handlers.telegram_standards import build_status_text # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.telegram_standards import build_status_text
@pytest.fixture @pytest.fixture
def _patch_base_bot_deps(tmp_path): def _patch_base_bot_deps(tmp_path):
patches = [ patches = [
patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path),
patch("apps.handlers.base_bot.signal.signal"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.signal.signal"),
patch("apps.handlers.base_bot.atexit.register"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.atexit.register"),
] ]
for p in patches: for p in patches:
p.start() p.start()
@@ -116,8 +116,8 @@ class TestStatusUptimes:
with ( with (
patch.object(bot, "send_message"), patch.object(bot, "send_message"),
patch("apps.handlers.base_bot.build_status_text", wraps=build_status_text) as mock_build, patch("aipass.skills.lib.telegram.apps.handlers.base_bot.build_status_text", wraps=build_status_text) as mock_build,
patch("apps.handlers.telegram_standards._tmux_session_exists", return_value=True), patch("aipass.skills.lib.telegram.apps.handlers.telegram_standards._tmux_session_exists", return_value=True),
): ):
bot._dispatch_command(42, ("status", "")) bot._dispatch_command(42, ("status", ""))
@@ -143,7 +143,7 @@ class TestStatusUptimes:
# Now check /status — conversation uptime should be near 0 # Now check /status — conversation uptime should be near 0
with ( with (
patch.object(bot, "send_message") as mock_send, patch.object(bot, "send_message") as mock_send,
patch("apps.handlers.telegram_standards._tmux_session_exists", return_value=True), patch("aipass.skills.lib.telegram.apps.handlers.telegram_standards._tmux_session_exists", return_value=True),
): ):
bot._dispatch_command(42, ("status", "")) bot._dispatch_command(42, ("status", ""))
@@ -163,7 +163,7 @@ class TestStatusUptimes:
with ( with (
patch.object(bot, "send_message") as mock_send, patch.object(bot, "send_message") as mock_send,
patch("apps.handlers.telegram_standards._tmux_session_exists", return_value=True), patch("aipass.skills.lib.telegram.apps.handlers.telegram_standards._tmux_session_exists", return_value=True),
): ):
bot._dispatch_command(42, ("status", "")) bot._dispatch_command(42, ("status", ""))
@@ -180,7 +180,7 @@ class TestBuildStatusText:
"""build_status_text renders daemon_uptime when provided.""" """build_status_text renders daemon_uptime when provided."""
def test_includes_daemon_uptime(self): def test_includes_daemon_uptime(self):
with patch("apps.handlers.telegram_standards._tmux_session_exists", return_value=True): with patch("aipass.skills.lib.telegram.apps.handlers.telegram_standards._tmux_session_exists", return_value=True):
text = build_status_text( text = build_status_text(
session_name="telegram-base", session_name="telegram-base",
branch_name="base", branch_name="base",
@@ -192,7 +192,7 @@ class TestBuildStatusText:
assert "Uptime: 0h 5m 0s" in text assert "Uptime: 0h 5m 0s" in text
def test_omits_daemon_uptime_when_none(self): def test_omits_daemon_uptime_when_none(self):
with patch("apps.handlers.telegram_standards._tmux_session_exists", return_value=True): with patch("aipass.skills.lib.telegram.apps.handlers.telegram_standards._tmux_session_exists", return_value=True):
text = build_status_text( text = build_status_text(
session_name="telegram-base", session_name="telegram-base",
branch_name="base", branch_name="base",
@@ -203,7 +203,7 @@ class TestBuildStatusText:
assert "Uptime: 1h 0m 0s" in text assert "Uptime: 1h 0m 0s" in text
def test_uptime_before_daemon_uptime(self): def test_uptime_before_daemon_uptime(self):
with patch("apps.handlers.telegram_standards._tmux_session_exists", return_value=True): with patch("aipass.skills.lib.telegram.apps.handlers.telegram_standards._tmux_session_exists", return_value=True):
text = build_status_text( text = build_status_text(
session_name="telegram-base", session_name="telegram-base",
branch_name="base", branch_name="base",
@@ -27,7 +27,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] from aipass.skills.lib.telegram.apps.handlers.base_bot import BaseBot
# ============================================= # =============================================
@@ -39,9 +39,9 @@ from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found]
def _patch_deps(tmp_path): def _patch_deps(tmp_path):
"""Patch signal and atexit for safe BaseBot construction.""" """Patch signal and atexit for safe BaseBot construction."""
patches = [ patches = [
patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path),
patch("apps.handlers.base_bot.signal.signal"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.signal.signal"),
patch("apps.handlers.base_bot.atexit.register"), patch("aipass.skills.lib.telegram.apps.handlers.base_bot.atexit.register"),
] ]
for p in patches: for p in patches:
p.start() p.start()
@@ -54,7 +54,7 @@ def _make_bot(tmp_path, _patch_deps, stream=False):
"""Create a BaseBot with test defaults.""" """Create a BaseBot with test defaults."""
workdir = tmp_path / "workdir" workdir = tmp_path / "workdir"
workdir.mkdir(exist_ok=True) workdir.mkdir(exist_ok=True)
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot = BaseBot( bot = BaseBot(
bot_id="stream_test", bot_id="stream_test",
bot_token="123:FAKETOKEN", bot_token="123:FAKETOKEN",
@@ -274,7 +274,7 @@ class TestStreamEdit:
mock_resp.__enter__ = MagicMock(return_value=mock_resp) mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False) mock_resp.__exit__ = MagicMock(return_value=False)
with patch("apps.handlers.base_bot.urlopen", return_value=mock_resp): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen", return_value=mock_resp):
ok, retry = bot._stream_edit(123, 456, "hello") ok, retry = bot._stream_edit(123, 456, "hello")
assert ok is True assert ok is True
assert retry == 0.0 assert retry == 0.0
@@ -295,7 +295,7 @@ class TestStreamEdit:
err = HTTPError("url", 429, "Too Many Requests", None, None) # type: ignore[arg-type] err = HTTPError("url", 429, "Too Many Requests", None, None) # type: ignore[arg-type]
err.read = MagicMock(return_value=body) err.read = MagicMock(return_value=body)
with patch("apps.handlers.base_bot.urlopen", side_effect=err): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen", side_effect=err):
ok, retry = bot._stream_edit(123, 456, "hello") ok, retry = bot._stream_edit(123, 456, "hello")
assert ok is False assert ok is False
assert retry == 15.0 assert retry == 15.0
@@ -315,14 +315,14 @@ class TestStreamEdit:
err = HTTPError("url", 400, "Bad Request", None, None) # type: ignore[arg-type] err = HTTPError("url", 400, "Bad Request", None, None) # type: ignore[arg-type]
err.read = MagicMock(return_value=body) err.read = MagicMock(return_value=body)
with patch("apps.handlers.base_bot.urlopen", side_effect=err): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen", side_effect=err):
ok, retry = bot._stream_edit(123, 456, "same text") ok, retry = bot._stream_edit(123, 456, "same text")
assert ok is True assert ok is True
assert retry == 0.0 assert retry == 0.0
def test_other_error(self, tmp_path, _patch_deps): def test_other_error(self, tmp_path, _patch_deps):
bot = _make_bot(tmp_path, _patch_deps) bot = _make_bot(tmp_path, _patch_deps)
with patch("apps.handlers.base_bot.urlopen", side_effect=ConnectionError("fail")): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen", side_effect=ConnectionError("fail")):
ok, retry = bot._stream_edit(123, 456, "hello") ok, retry = bot._stream_edit(123, 456, "hello")
assert ok is False assert ok is False
assert retry == 0.0 assert retry == 0.0
@@ -338,7 +338,7 @@ class TestPendingFileStreaming:
def test_no_streaming_key_when_off(self, tmp_path, _patch_deps): def test_no_streaming_key_when_off(self, tmp_path, _patch_deps):
bot = _make_bot(tmp_path, _patch_deps, stream=False) bot = _make_bot(tmp_path, _patch_deps, stream=False)
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot.pending_file = tmp_path / "bot-stream_test.json" bot.pending_file = tmp_path / "bot-stream_test.json"
bot.write_pending_file(123, 1, 2) bot.write_pending_file(123, 1, 2)
data = json.loads(bot.pending_file.read_text(encoding="utf-8")) data = json.loads(bot.pending_file.read_text(encoding="utf-8"))
@@ -346,7 +346,7 @@ class TestPendingFileStreaming:
def test_streaming_true_when_on(self, tmp_path, _patch_deps): def test_streaming_true_when_on(self, tmp_path, _patch_deps):
bot = _make_bot(tmp_path, _patch_deps, stream=True) bot = _make_bot(tmp_path, _patch_deps, stream=True)
with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
bot.pending_file = tmp_path / "bot-stream_test.json" bot.pending_file = tmp_path / "bot-stream_test.json"
bot.write_pending_file(123, 1, 2) bot.write_pending_file(123, 1, 2)
data = json.loads(bot.pending_file.read_text(encoding="utf-8")) data = json.loads(bot.pending_file.read_text(encoding="utf-8"))