import asyncio import pytest from pathlib import Path from types import SimpleNamespace from hermes_cli import kanban_db as kb from unittest.mock import AsyncMock, MagicMock, patch # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def kanban_home(tmp_path, monkeypatch): home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setattr(Path, "home", lambda: tmp_path) # Allow the kanban notifier path-validator to upload artifacts the # tests write under ``tmp_path``. Without this, every artifact-delivery # test silently drops files because ``tmp_path`` isn't inside the # default ``MEDIA_DELIVERY_SAFE_ROOTS`` cache dirs. monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(tmp_path)) kb.init_db() return home def _assert_inherited_notify_sub(subs: list[dict]) -> None: assert len(subs) == 1 assert subs[0]["platform"] == "telegram" assert subs[0]["chat_id"] == "chat1" assert subs[0]["thread_id"] == "topic1" assert subs[0]["user_id"] == "user1" assert subs[0]["notifier_profile"] == "default" # --------------------------------------------------------------------------- # Regression: gateway watchers must not double-init the kanban DB. # # Both the notifier watcher (`_kanban_notifier_watcher`) and the dispatcher # tick (`_tick_once_for_board`) used to call `_kb.connect(board=slug)` # immediately followed by `_kb.init_db(board=slug)`. Since `connect()` # already runs the schema + idempotent migration on first open per process, # the explicit `init_db()` was redundant — and worse, `init_db()` # deliberately busts the per-process cache and re-runs the migration on a # *second* connection, which races the first. On legacy DBs this surfaced # as `duplicate column name: ` (now tolerated by # `_add_column_if_missing`) and intermittent `database is locked` errors # (issue #21378). # # The fix removes the `init_db()` calls in both watchers; this regression # test pins that behaviour so we don't reintroduce them. # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home): """`/kanban --board create ...` must subscribe on that board. The gateway handler currently auto-subscribes after `/kanban create`, but the create detection must still work when the shared `--board` flag appears before the subcommand, and the subscription must land in that board's DB rather than the ambient/default board. """ from gateway.run import GatewayRunner from gateway.config import Platform kb.create_board("projx") runner = object.__new__(GatewayRunner) source = SimpleNamespace( platform=Platform.TELEGRAM, chat_id="chat1", chat_type="dm", thread_id="20197", user_id="u1", ) event = SimpleNamespace( text='/kanban --board projx create "hello" --assignee alice', source=source, message_id="462", reply_to_message_id=None, ) out = await GatewayRunner._handle_kanban_command(runner, event) assert "subscribed" in out.lower() conn = kb.connect(board="projx") try: subs = kb.list_notify_subs(conn) tasks = kb.list_tasks(conn) finally: conn.close() assert [t.title for t in tasks] == ["hello"] assert len(subs) == 1 assert subs[0]["chat_id"] == "chat1" assert subs[0]["thread_id"] == "20197" assert subs[0]["delivery_metadata"] == { "chat_type": "dm", "direct_messages_topic_id": "20197", "telegram_dm_topic_reply_fallback": True, "telegram_reply_to_message_id": "462", "thread_id": "20197", } conn = kb.connect(board="default") try: assert kb.list_notify_subs(conn) == [] finally: conn.close() @pytest.mark.asyncio async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_path, monkeypatch): """Missing artifact paths are silently skipped — they may have been referenced by name only. The notifier must not crash and must still deliver any artifacts that do exist.""" import hermes_cli.kanban_db as kb from gateway.run import GatewayRunner from gateway.config import Platform from tools import kanban_tools as kt # Allow ``tmp_path`` through the media-delivery safety filter. See the # companion test for the full explanation. monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(tmp_path)) real_pdf = tmp_path / "real.pdf" real_pdf.write_bytes(b"%PDF-fake") conn = kb.connect() try: tid = kb.create_task(conn, title="t", assignee="worker1") kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") finally: conn.close() import os os.environ["HERMES_KANBAN_TASK"] = tid try: kt._handle_complete({ "summary": "one real, one ghost", "artifacts": [str(real_pdf), "/tmp/definitely-does-not-exist.pdf"], }) finally: os.environ.pop("HERMES_KANBAN_TASK", None) runner = object.__new__(GatewayRunner) runner._running = True runner._kanban_sub_fail_counts = {} fake_adapter = MagicMock() fake_adapter.name = "telegram" documents_uploaded: list = [] async def _send(chat_id, msg, metadata=None): runner._running = False async def _send_document(chat_id, file_path, metadata=None, **_kw): documents_uploaded.append(file_path) fake_adapter.send = AsyncMock(side_effect=_send) fake_adapter.send_document = AsyncMock(side_effect=_send_document) fake_adapter.send_multiple_images = AsyncMock() from gateway.platforms.base import BasePlatformAdapter fake_adapter.extract_local_files = BasePlatformAdapter.extract_local_files runner.adapters = {Platform.TELEGRAM: fake_adapter} _orig_sleep = asyncio.sleep async def _fast_sleep(_): await _orig_sleep(0) with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep): await asyncio.wait_for( runner._kanban_notifier_watcher(interval=1), timeout=10.0, ) # Only the real file was uploaded. assert len(documents_uploaded) == 1 assert "real.pdf" in documents_uploaded[0]