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]