Files
matrix-hermes/tests/test_kanban.py
T
shawn e8b389d6b7 feat: add kanban board monitoring and Matrix notification bridge
- KanbanDB: read-only SQLite access to kanban state (tasks, events, comments, subs)
- KanbanMonitor: async poll loop that watches kanban DB, formats events, sends via Matrix
- format_event: human-readable event formatting (created, claimed, completed, etc.)
- CLI: matrix-hermes kanban monitor/subscribe/unsubscribe/status commands
- 29 tests covering DB, event formatting, monitor lifecycle, and notifications
- Version bump 0.3.0 → 0.4.0
2026-05-23 12:24:23 -04:00

479 lines
15 KiB
Python

"""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