"""Tests for matrix_hermes.kanban — kanban board monitoring and notifications.""" import sqlite3 import tempfile import time from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from matrix_hermes.kanban import ( KanbanDB, KanbanEvent, KanbanMonitor, KanbanSubscription, KanbanTask, format_event, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def kanban_db_path(): """Create a temporary kanban database with test schema and data.""" with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: db_path = f.name conn = sqlite3.connect(db_path) conn.executescript(""" CREATE TABLE tasks ( id TEXT PRIMARY KEY, title TEXT NOT NULL, assignee TEXT, status TEXT NOT NULL, priority INTEGER DEFAULT 0, created_by TEXT, created_at INTEGER NOT NULL ); CREATE TABLE task_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, run_id INTEGER, kind TEXT NOT NULL, payload TEXT, created_at INTEGER NOT NULL ); CREATE TABLE task_comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, author TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE kanban_notify_subs ( task_id TEXT NOT NULL, platform TEXT NOT NULL, chat_id TEXT NOT NULL, thread_id TEXT NOT NULL DEFAULT '', user_id TEXT, notifier_profile TEXT, created_at INTEGER NOT NULL, last_event_id INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (task_id, platform, chat_id, thread_id) ); -- Test tasks INSERT INTO tasks VALUES ('t_test1', 'Test task one', 'coder', 'running', 1, 'user', 1000), ('t_test2', 'Test task two', 'reviewer', 'todo', 0, 'user', 1000); -- Test events INSERT INTO task_events (task_id, kind, payload, created_at) VALUES ('t_test1', 'created', '{"assignee":"coder","status":"todo"}', 1000), ('t_test1', 'claimed', '{"lock":"coder:123"}', 1005), ('t_test1', 'spawned', '{"pid":42}', 1010), ('t_test2', 'created', '{"assignee":"reviewer"}', 1020); -- Test subscription INSERT INTO kanban_notify_subs VALUES ('t_test1', 'matrix', '!room1:example.org', '', '@bot:example.org', 'monitor', 2000, 1); """) conn.commit() conn.close() yield db_path # Cleanup Path(db_path).unlink(missing_ok=True) @pytest.fixture def mock_matrix_client(): """Create a mock MatrixClient that is 'connected'.""" client = MagicMock() client.connected = True client.user_id = "@bot:example.org" client.send_message = AsyncMock(return_value=MagicMock(success=True, event_id="$evt123")) client.get_joined_rooms = AsyncMock(return_value=["!room1:example.org"]) client.join_room = AsyncMock(return_value=True) return client # --------------------------------------------------------------------------- # KanbanDB tests # --------------------------------------------------------------------------- class TestKanbanDB: """Tests for KanbanDB.""" def test_connect_and_close(self, kanban_db_path): db = KanbanDB(kanban_db_path) db.connect() assert db._conn is not None db.close() assert db._conn is None def test_get_task(self, kanban_db_path): db = KanbanDB(kanban_db_path) task = db.get_task("t_test1") assert task is not None assert task.id == "t_test1" assert task.title == "Test task one" assert task.assignee == "coder" assert task.status == "running" db.close() def test_get_task_nonexistent(self, kanban_db_path): db = KanbanDB(kanban_db_path) task = db.get_task("t_nonexistent") assert task is None db.close() def test_get_events_since(self, kanban_db_path): db = KanbanDB(kanban_db_path) events = db.get_events_since(0) assert len(events) == 4 assert events[0].kind == "created" assert events[0].task_id == "t_test1" # After first event events2 = db.get_events_since(events[0].id) assert len(events2) == 3 assert events2[0].kind == "claimed" db.close() def test_get_events_since_limit(self, kanban_db_path): db = KanbanDB(kanban_db_path) events = db.get_events_since(0, limit=2) assert len(events) == 2 db.close() def test_get_latest_event_id(self, kanban_db_path): db = KanbanDB(kanban_db_path) latest = db.get_latest_event_id() assert latest > 0 db.close() def test_get_matrix_subscriptions(self, kanban_db_path): db = KanbanDB(kanban_db_path) subs = db.get_matrix_subscriptions() assert len(subs) == 1 assert subs[0].task_id == "t_test1" assert subs[0].chat_id == "!room1:example.org" assert subs[0].platform == "matrix" db.close() def test_get_comments_since(self, kanban_db_path): # Add a comment conn = sqlite3.connect(kanban_db_path) conn.execute( "INSERT INTO task_comments (task_id, author, body, created_at) " "VALUES ('t_test1', 'user', 'Nice work!', 1050)" ) conn.commit() conn.close() db = KanbanDB(kanban_db_path) comments = db.get_comments_since(0) assert len(comments) == 1 assert comments[0]["author"] == "user" assert comments[0]["body"] == "Nice work!" db.close() def test_update_subscription_last_event(self, kanban_db_path): db = KanbanDB(kanban_db_path) db.update_subscription_last_event("t_test1", "!room1:example.org", 99) # Verify conn = sqlite3.connect(kanban_db_path) row = conn.execute( "SELECT last_event_id FROM kanban_notify_subs " "WHERE task_id = 't_test1' AND platform = 'matrix'" ).fetchone() conn.close() assert row[0] == 99 def test_auto_connect(self, kanban_db_path): db = KanbanDB(kanban_db_path) # Don't call connect explicitly task = db.get_task("t_test1") assert task is not None db.close() # --------------------------------------------------------------------------- # format_event tests # --------------------------------------------------------------------------- class TestFormatEvent: """Tests for format_event.""" def test_created(self): event = KanbanEvent( id=1, task_id="t_test1", kind="created", payload={"assignee": "coder", "status": "todo"}, created_at=1000, ) task = KanbanTask(id="t_test1", title="My Task") msg = format_event(event, task) assert "New task" in msg assert "My Task" in msg def test_claimed(self): event = KanbanEvent( id=2, task_id="t_test1", kind="claimed", payload={"lock": "coder:123"}, created_at=1005, ) msg = format_event(event) assert "Claimed" in msg assert "coder" in msg def test_spawned(self): event = KanbanEvent( id=3, task_id="t_test1", kind="spawned", payload={"pid": 42}, created_at=1010, ) msg = format_event(event) assert "spawned" in msg.lower() assert "42" in msg def test_completed_with_summary(self): event = KanbanEvent( id=4, task_id="t_test1", kind="completed", payload={"summary": "All done!"}, created_at=2000, ) msg = format_event(event) assert "Completed" in msg assert "All done!" in msg def test_blocked_with_reason(self): event = KanbanEvent( id=5, task_id="t_test1", kind="blocked", payload={"reason": "Need auth token"}, created_at=2000, ) msg = format_event(event) assert "Blocked" in msg assert "Need auth token" in msg def test_timed_out(self): event = KanbanEvent( id=6, task_id="t_test1", kind="timed_out", created_at=2000, ) msg = format_event(event) assert "Timed out" in msg def test_released(self): event = KanbanEvent( id=7, task_id="t_test1", kind="released", created_at=2000, ) msg = format_event(event) assert "Released" in msg def test_unknown_kind(self): event = KanbanEvent( id=8, task_id="t_test1", kind="weird_event", created_at=2000, ) msg = format_event(event) assert "weird_event" in msg assert "t_test1" in msg def test_timestamp_iso(self): event = KanbanEvent( id=1, task_id="t_test1", kind="created", created_at=1000, ) assert "1970-01-01" in event.timestamp_iso # --------------------------------------------------------------------------- # KanbanMonitor tests # --------------------------------------------------------------------------- class TestKanbanMonitor: """Tests for KanbanMonitor.""" def test_init_requires_connected_client(self, kanban_db_path): client = MagicMock() client.connected = False db = KanbanDB(kanban_db_path) with pytest.raises(RuntimeError, match="connected"): KanbanMonitor(client, db) def test_init_success(self, kanban_db_path, mock_matrix_client): db = KanbanDB(kanban_db_path) monitor = KanbanMonitor(mock_matrix_client, db, poll_interval=1.0) assert monitor.events_processed == 0 assert monitor.notifications_sent == 0 @pytest.mark.asyncio async def test_start_and_stop(self, kanban_db_path, mock_matrix_client): db = KanbanDB(kanban_db_path) monitor = KanbanMonitor(mock_matrix_client, db, poll_interval=0.1) # Start await monitor.start() assert monitor._running # Let it poll a couple times await asyncio_sleep(0.3) # Stop await monitor.stop() assert not monitor._running @pytest.mark.asyncio async def test_event_handler_called(self, kanban_db_path, mock_matrix_client): db = KanbanDB(kanban_db_path) monitor = KanbanMonitor(mock_matrix_client, db, poll_interval=0.1) handler_events = [] async def my_handler(event, task): handler_events.append(event.kind) monitor.on_event(my_handler) await monitor.start() # Let the poll loop initialize and capture baseline event ID await asyncio_sleep(0.2) # Insert a new event after the monitor has started conn = sqlite3.connect(kanban_db_path) conn.execute( "INSERT INTO task_events (task_id, kind, payload, created_at) " "VALUES ('t_test1', 'blocked', '{\"reason\":\"test\"}', 1200)" ) conn.commit() conn.close() await asyncio_sleep(0.5) await monitor.stop() # Should have processed new event assert len(handler_events) > 0 @pytest.mark.asyncio async def test_notifies_subscribers(self, kanban_db_path, mock_matrix_client): db = KanbanDB(kanban_db_path) monitor = KanbanMonitor(mock_matrix_client, db, poll_interval=0.1) await monitor.start() # Let the poll loop initialize and capture baseline event ID await asyncio_sleep(0.2) # Add a new event after starting the monitor conn = sqlite3.connect(kanban_db_path) conn.execute( "INSERT INTO task_events (task_id, kind, payload, created_at) " "VALUES ('t_test1', 'completed', '{\"summary\":\"Done!\"}', 1100)" ) conn.commit() conn.close() await asyncio_sleep(0.5) await monitor.stop() # Should have sent at least one notification assert monitor.notifications_sent >= 1 # Verify send_message was called assert mock_matrix_client.send_message.called @pytest.mark.asyncio async def test_no_subscriptions_no_notifications(self, kanban_db_path, mock_matrix_client): # Remove the subscription conn = sqlite3.connect(kanban_db_path) conn.execute("DELETE FROM kanban_notify_subs") conn.commit() conn.close() # Add a new event conn = sqlite3.connect(kanban_db_path) conn.execute( "INSERT INTO task_events (task_id, kind, payload, created_at) " "VALUES ('t_test1', 'completed', '{}', 1100)" ) conn.commit() conn.close() db = KanbanDB(kanban_db_path) monitor = KanbanMonitor(mock_matrix_client, db, poll_interval=0.1) await monitor.start() await asyncio_sleep(0.3) await monitor.stop() # No notifications should be sent (no subscriptions) assert monitor.notifications_sent == 0 # --------------------------------------------------------------------------- # KanbanSubscription tests # --------------------------------------------------------------------------- class TestKanbanSubscription: """Tests for KanbanSubscription dataclass.""" def test_defaults(self): sub = KanbanSubscription( task_id="t_test", platform="matrix", chat_id="!room:example.org", ) assert sub.thread_id == "" assert sub.user_id == "" assert sub.last_event_id == 0 def test_full(self): sub = KanbanSubscription( task_id="t_test", platform="matrix", chat_id="!room:example.org", thread_id="$evt_thread", user_id="@bot:example.org", last_event_id=42, ) assert sub.thread_id == "$evt_thread" assert sub.user_id == "@bot:example.org" assert sub.last_event_id == 42 # --------------------------------------------------------------------------- # KanbanTask tests # --------------------------------------------------------------------------- class TestKanbanTask: """Tests for KanbanTask dataclass.""" def test_minimal(self): task = KanbanTask(id="t123", title="Minimal task") assert task.id == "t123" assert task.title == "Minimal task" assert task.assignee == "" assert task.status == "todo" assert task.priority == 0 def test_full(self): task = KanbanTask( id="t456", title="Full task", assignee="coder", status="running", priority=1, created_by="user", ) assert task.assignee == "coder" assert task.status == "running" # Helpers import asyncio as _asyncio def asyncio_sleep(seconds): """Helper that works in both sync and async contexts for test waiting.""" try: loop = _asyncio.get_running_loop() return _asyncio.sleep(seconds) except RuntimeError: time.sleep(seconds) fut = _asyncio.Future() fut.set_result(None) return fut