hermes-agent/tests/hermes_cli/test_kanban_notify.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

196 lines
6.4 KiB
Python

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: <col>` (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 <slug> 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]