172 lines
6.7 KiB
Python
172 lines
6.7 KiB
Python
"""Tests for the Kanban board endpoints and autonomous dispatch logic."""
|
|
import json
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture()
|
|
def sync_threads(server_module, monkeypatch):
|
|
"""Run ``threading.Thread`` targets synchronously so dispatch is deterministic."""
|
|
class _SyncThread:
|
|
def __init__(self, target=None, args=(), kwargs=None, daemon=None):
|
|
self._target = target
|
|
self._args = args
|
|
self._kwargs = kwargs or {}
|
|
|
|
def start(self):
|
|
if self._target:
|
|
self._target(*self._args, **self._kwargs)
|
|
|
|
monkeypatch.setattr(server_module.threading, "Thread", _SyncThread)
|
|
return server_module
|
|
|
|
|
|
def _create(client, **kw):
|
|
payload = {"title": "T", "body": "", "status": "triage",
|
|
"priority": "medium", "assignee": ""}
|
|
payload.update(kw)
|
|
return client.post("/api/kanban/tasks", json=payload).json()
|
|
|
|
|
|
def test_board_empty(client):
|
|
body = client.get("/api/kanban/board").json()
|
|
assert body["total"] == 0
|
|
assert set(body["columns"]) == {"triage", "todo", "ready", "in_progress", "blocked", "done"}
|
|
|
|
|
|
def test_create_and_get_task(client):
|
|
task = _create(client, title="Write tests", assignee="")
|
|
assert task["title"] == "Write tests"
|
|
assert task["status"] == "triage"
|
|
fetched = client.get(f"/api/kanban/tasks/{task['id']}").json()
|
|
assert fetched["id"] == task["id"]
|
|
|
|
|
|
def test_get_missing_task_404(client):
|
|
assert client.get("/api/kanban/tasks/abc123").status_code == 404
|
|
|
|
|
|
def test_board_groups_by_status(client):
|
|
_create(client, status="todo")
|
|
_create(client, status="done")
|
|
board = client.get("/api/kanban/board").json()
|
|
assert len(board["columns"]["todo"]) == 1
|
|
assert len(board["columns"]["done"]) == 1
|
|
assert board["total"] == 2
|
|
# filter by status query
|
|
filtered = client.get("/api/kanban/board?status=todo").json()
|
|
assert filtered["total"] == 1
|
|
|
|
|
|
def test_update_task(client):
|
|
task = _create(client)
|
|
updated = client.patch(f"/api/kanban/tasks/{task['id']}",
|
|
json={"title": "renamed", "priority": "high"}).json()
|
|
assert updated["title"] == "renamed"
|
|
assert updated["priority"] == "high"
|
|
|
|
|
|
def test_update_missing_404(client):
|
|
assert client.patch("/api/kanban/tasks/abc123", json={"title": "x"}).status_code == 404
|
|
|
|
|
|
def test_complete_block_unblock(client):
|
|
task = _create(client)
|
|
tid = task["id"]
|
|
assert client.post(f"/api/kanban/tasks/{tid}/complete", json={"summary": "done!"}).json()["status"] == "done"
|
|
assert client.post(f"/api/kanban/tasks/{tid}/block", json={"reason": "stuck"}).json()["status"] == "blocked"
|
|
unblocked = client.post(f"/api/kanban/tasks/{tid}/unblock").json()
|
|
assert unblocked["status"] == "ready"
|
|
assert unblocked["block_reason"] == ""
|
|
|
|
|
|
def test_comments(client):
|
|
task = _create(client)
|
|
updated = client.post(f"/api/kanban/tasks/{task['id']}/comments", json={"message": "hi there"}).json()
|
|
assert updated["comments"][0]["message"] == "hi there"
|
|
|
|
|
|
def test_links_add_and_remove(client):
|
|
parent = _create(client)
|
|
child = _create(client)
|
|
r = client.post("/api/kanban/links", json={"parent_id": parent["id"], "child_id": child["id"]})
|
|
assert r.json() == {"status": "linked"}
|
|
linked = client.get(f"/api/kanban/tasks/{parent['id']}").json()
|
|
assert {"parent": parent["id"], "child": child["id"]} in linked["links"]
|
|
r = client.delete(f"/api/kanban/links?parent_id={parent['id']}&child_id={child['id']}")
|
|
assert r.json() == {"status": "unlinked"}
|
|
unlinked = client.get(f"/api/kanban/tasks/{parent['id']}").json()
|
|
assert unlinked["links"] == []
|
|
|
|
|
|
def test_link_missing_task_404(client):
|
|
parent = _create(client)
|
|
assert client.post("/api/kanban/links",
|
|
json={"parent_id": parent["id"], "child_id": "abcdef"}).status_code == 404
|
|
|
|
|
|
def test_specify_moves_triage_to_todo(client):
|
|
task = _create(client, status="triage")
|
|
assert client.post(f"/api/kanban/tasks/{task['id']}/specify").json()["status"] == "todo"
|
|
|
|
|
|
def test_decompose_creates_children(client):
|
|
task = _create(client, body="- first subtask\n- second subtask\n\n")
|
|
body = client.post(f"/api/kanban/tasks/{task['id']}/decompose").json()
|
|
assert body["parent"] == task["id"]
|
|
assert len(body["children"]) == 2
|
|
titles = [c["title"] for c in body["children"]]
|
|
assert "first subtask" in titles
|
|
|
|
|
|
def test_dispatch_requires_valid_assignee(client):
|
|
task = _create(client, assignee="")
|
|
assert client.post(f"/api/kanban/tasks/{task['id']}/dispatch").status_code == 400
|
|
|
|
|
|
def test_create_with_agent_assignee_dispatches(client, sync_threads, monkeypatch):
|
|
monkeypatch.setattr(sync_threads, "execute_agent", lambda agent, prompt: "agent finished the job")
|
|
task = _create(client, title="Do work", assignee="opencode")
|
|
# With synchronous dispatch, the task runs to completion immediately.
|
|
assert task["status"] == "done"
|
|
assert task["summary"].startswith("agent finished")
|
|
assert any("opencode" in c["message"] for c in task["comments"])
|
|
|
|
|
|
def test_dispatch_failure_blocks_task(client, sync_threads, monkeypatch):
|
|
monkeypatch.setattr(sync_threads, "execute_agent", lambda agent, prompt: "⚠ Agent not installed")
|
|
task = _create(client, title="Do work", assignee="hermes")
|
|
assert task["status"] == "blocked"
|
|
assert "not installed" in task["block_reason"]
|
|
|
|
|
|
def test_create_with_custom_registered_agent_dispatches(client, sync_threads, monkeypatch):
|
|
# Dispatch validation follows the live registry, not a hardcoded list —
|
|
# custom agents (e.g. codex, jarvis) must be dispatchable too.
|
|
sync_threads.AGENT_REGISTRY_FILE.write_text(json.dumps({
|
|
"jarvis": {"name": "jarvis", "type": "cli", "binary": "jarvis",
|
|
"run_args": ["jarvis", "{message}"]},
|
|
}))
|
|
monkeypatch.setattr(sync_threads, "execute_agent", lambda agent, prompt: "jarvis did it")
|
|
task = _create(client, title="Do work", assignee="jarvis")
|
|
assert task["status"] == "done"
|
|
assert any("jarvis" in c["message"] for c in task["comments"])
|
|
|
|
|
|
def test_bulk_dispatch_endpoint(client, sync_threads, monkeypatch):
|
|
monkeypatch.setattr(sync_threads, "execute_agent", lambda agent, prompt: "ok done")
|
|
|
|
# Write task files directly so they start eligible (todo/ready with an agent
|
|
# assignee) — going through the create endpoint would auto-dispatch them.
|
|
def _seed(tid, status, assignee):
|
|
sync_threads.save_kanban_task({
|
|
"id": tid, "title": "t", "body": "", "status": status,
|
|
"priority": "medium", "assignee": assignee, "comments": [], "links": [],
|
|
})
|
|
|
|
_seed("aaaaaa", "todo", "gemini")
|
|
_seed("bbbbbb", "ready", "opencode")
|
|
_seed("cccccc", "triage", "") # not eligible
|
|
body = client.post("/api/kanban/dispatch").json()
|
|
assert len(body["dispatched"]) == 2
|