e8b389d6b7
- 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
479 lines
15 KiB
Python
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
|