Add unit test suite for server.py and scheduler.py

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
zumayaaustin 2026-07-08 23:09:02 +00:00
parent fb3a1979dc
commit 33e792bd42
8 changed files with 792 additions and 0 deletions

4
.gitignore vendored
View File

@ -1,6 +1,10 @@
__pycache__/
*.pyc
.env
.venv/
.pytest_cache/
.coverage
htmlcov/
*.egg-info/
dist/
node_modules/

4
pytest.ini Normal file
View File

@ -0,0 +1,4 @@
[pytest]
testpaths = tests
python_files = test_*.py
addopts = -q

4
requirements-dev.txt Normal file
View File

@ -0,0 +1,4 @@
-r requirements.txt
pytest>=8.0.0
pytest-cov>=5.0.0
httpx>=0.27.0

51
tests/conftest.py Normal file
View File

@ -0,0 +1,51 @@
"""Shared pytest fixtures for the Agentic OS test suite.
The application code (``server.py``) computes a number of module-level path
constants from ``BASE_DIR`` at import time. To keep tests hermetic no writes
to the real repository the fixtures below redirect every one of those
constants at a temporary directory and rebuild the minimal folder layout the
endpoints expect.
"""
import importlib
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
@pytest.fixture()
def server_module(tmp_path, monkeypatch):
"""Import ``server`` with all filesystem paths redirected to ``tmp_path``.
Returns the imported module with its path globals patched so tests can
exercise endpoints without touching the real project directories.
"""
server = importlib.import_module("server")
base = tmp_path
for sub in ["data", "data/kanban", "brain", "brain/journal", "audit",
"skills", "scheduler/jobs", "registry", "standards",
"prompts", "backups", "agents"]:
(base / sub).mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(server, "BASE_DIR", base)
monkeypatch.setattr(server, "KANBAN_DIR", base / "data" / "kanban")
monkeypatch.setattr(server, "GOALS_FILE", base / "data" / "goals.json")
monkeypatch.setattr(server, "JOURNAL_DIR", base / "brain" / "journal")
monkeypatch.setattr(server, "CHAT_HISTORY_FILE",
base / "data" / "chat-history.json")
monkeypatch.setattr(server, "_terminal_cwd", str(base))
return server
@pytest.fixture()
def client(server_module):
from fastapi.testclient import TestClient
with TestClient(server_module.app) as c:
yield c

65
tests/test_scheduler.py Normal file
View File

@ -0,0 +1,65 @@
"""Unit tests for ``scheduler/scheduler.py``."""
import importlib
import json
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SCHEDULER_DIR = REPO_ROOT / "scheduler"
if str(SCHEDULER_DIR) not in sys.path:
sys.path.insert(0, str(SCHEDULER_DIR))
@pytest.fixture()
def scheduler_module(tmp_path, monkeypatch):
sched = importlib.import_module("scheduler")
base = tmp_path / "scheduler"
(base / "jobs").mkdir(parents=True)
(tmp_path / "audit").mkdir()
monkeypatch.setattr(sched, "BASE_DIR", base)
monkeypatch.setattr(sched, "JOBS_DIR", base / "jobs")
return sched
def test_run_skill_appends_audit(scheduler_module, capsys):
scheduler_module.run_skill("heartbeat")
audit_file = scheduler_module.BASE_DIR.parent / "audit" / "audit.log"
entry = json.loads(audit_file.read_text().strip())
assert entry["action"] == "scheduler_run"
assert entry["skill"] == "heartbeat"
assert "timestamp" in entry
assert "Ran skill: heartbeat" in capsys.readouterr().out
def test_load_jobs_registers_enabled(scheduler_module):
(scheduler_module.JOBS_DIR / "hb.json").write_text(json.dumps({
"id": "hb1", "name": "Heartbeat", "skill": "heartbeat",
"cron": "*/5 * * * *", "enabled": True,
}))
sched = scheduler_module.BackgroundScheduler()
scheduler_module.load_jobs(sched)
jobs = sched.get_jobs()
assert len(jobs) == 1
assert jobs[0].id == "hb1"
assert jobs[0].name == "Heartbeat"
def test_load_jobs_skips_disabled(scheduler_module):
(scheduler_module.JOBS_DIR / "off.json").write_text(json.dumps({
"id": "off1", "name": "Disabled", "skill": "x",
"cron": "0 0 * * *", "enabled": False,
}))
sched = scheduler_module.BackgroundScheduler()
scheduler_module.load_jobs(sched)
assert sched.get_jobs() == []
def test_load_jobs_falls_back_to_name_as_id(scheduler_module):
(scheduler_module.JOBS_DIR / "noid.json").write_text(json.dumps({
"name": "NoId", "skill": "x", "cron": "0 0 * * *", "enabled": True,
}))
sched = scheduler_module.BackgroundScheduler()
scheduler_module.load_jobs(sched)
assert sched.get_jobs()[0].id == "NoId"

View File

@ -0,0 +1,359 @@
"""Endpoint tests for ``server.py`` exercised through FastAPI's TestClient."""
import json
import pytest
# ─── Status / static ──────────────────────────────────────────────
def test_status_ok(client, server_module, monkeypatch):
monkeypatch.setattr(server_module.shutil, "which", lambda name: None)
r = client.get("/api/status")
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
assert {a["name"] for a in body["agents"]} == {"opencode", "hermes", "gemini"}
assert body["skills_count"] == 0
def test_index_without_dashboard(client):
r = client.get("/")
assert r.status_code == 200
assert "Agentic OS" in r.text
def test_favicon_endpoints(client):
for path in ("/favicon.ico", "/favicon.svg"):
r = client.get(path)
assert r.status_code == 200
assert r.headers["content-type"].startswith("image/svg")
# ─── Brain ─────────────────────────────────────────────────────────
def test_brain_list_and_get_and_update(client, server_module):
brain = server_module.BASE_DIR / "brain"
(brain / "memory.md").write_text("remember this")
r = client.get("/api/brain")
assert r.json()["memory.md"] == "remember this"
r = client.get("/api/brain/memory.md")
assert r.json() == {"name": "memory.md", "content": "remember this"}
r = client.put("/api/brain/memory.md", json={"content": "new content"})
assert r.status_code == 200
assert (brain / "memory.md").read_text() == "new content"
def test_brain_get_missing_404(client):
assert client.get("/api/brain/ghost.md").status_code == 404
# ─── Skills ────────────────────────────────────────────────────────
def _make_skill(server_module, name, skill_md="", eval_data=None, scores=None):
d = server_module.BASE_DIR / "skills" / name
d.mkdir(parents=True, exist_ok=True)
if skill_md:
(d / "SKILL.md").write_text(skill_md)
if eval_data is not None:
(d / "eval.json").write_text(json.dumps(eval_data))
if scores is not None:
(d / "score-history.json").write_text(json.dumps(scores))
return d
def test_list_skills(client, server_module):
_make_skill(server_module, "code-review", skill_md="Review code",
eval_data={"criteria": ["clarity"]}, scores=[{"score": 8}])
_make_skill(server_module, "_template", skill_md="ignored")
skills = client.get("/api/skills").json()
names = [s["name"] for s in skills]
assert "code-review" in names
assert "_template" not in names
cr = next(s for s in skills if s["name"] == "code-review")
assert cr["eval_criteria"] == ["clarity"]
assert cr["scores"] == [{"score": 8}]
def test_get_skill_and_missing(client, server_module):
_make_skill(server_module, "brainstorming", skill_md="Ideas",
eval_data={"criteria": []}, scores=[{"score": 5}])
r = client.get("/api/skills/brainstorming")
assert r.json()["skill"] == "Ideas"
assert client.get("/api/skills/nope").status_code == 404
def test_get_skill_eval(client, server_module):
_make_skill(server_module, "tdd-cycle", scores=[{"score": 7}])
assert client.get("/api/skills/tdd-cycle/eval").json() == {"scores": [{"score": 7}]}
assert client.get("/api/skills/other/eval").json() == {"scores": []}
@pytest.mark.parametrize("name,expected_agent", [
("devops-audit", "opencode"),
("research-synthesis", "gemini"),
("generic-skill", "opencode"),
])
def test_run_skill_auto_routes(client, server_module, monkeypatch, name, expected_agent):
_make_skill(server_module, name, skill_md="do stuff")
captured = {}
def fake_exec(agent, prompt):
captured["agent"] = agent
return "done"
monkeypatch.setattr(server_module, "execute_agent", fake_exec)
r = client.post(f"/api/skills/{name}/run", json={"input": "go", "agent": "auto"})
assert r.status_code == 200
assert r.json()["agent"] == expected_agent
assert captured["agent"] == expected_agent
def test_run_skill_uses_skill_md_primary(client, server_module, monkeypatch):
_make_skill(server_module, "meeting-notes", skill_md="Primary: hermes\nrest")
monkeypatch.setattr(server_module, "execute_agent", lambda a, p: "ok")
r = client.post("/api/skills/meeting-notes/run", json={"agent": "auto"})
assert r.json()["agent"] == "hermes"
def test_run_skill_missing_404(client, server_module, monkeypatch):
monkeypatch.setattr(server_module, "execute_agent", lambda a, p: "ok")
assert client.post("/api/skills/ghost/run", json={}).status_code == 404
# ─── Scheduler jobs ────────────────────────────────────────────────
def test_scheduler_job_crud(client, server_module):
assert client.get("/api/scheduler/jobs").json() == []
created = client.post("/api/scheduler/jobs", json={
"name": "nightly audit", "skill": "devops-audit", "cron": "0 0 * * *",
}).json()
assert created["name"] == "nightly audit"
jobs = client.get("/api/scheduler/jobs").json()
assert len(jobs) == 1
# file name derives from the job name with spaces replaced.
assert (server_module.BASE_DIR / "scheduler" / "jobs" / "nightly_audit.json").exists()
assert client.delete(f"/api/scheduler/jobs/{created['id']}").json() == {"status": "deleted"}
assert client.get("/api/scheduler/jobs").json() == []
def test_delete_missing_job_404(client):
assert client.delete("/api/scheduler/jobs/deadbeef").status_code == 404
# ─── Audit ─────────────────────────────────────────────────────────
def test_audit_empty_and_limit(client, server_module):
assert client.get("/api/audit").json() == {"entries": []}
for i in range(5):
server_module.append_audit({"action": "act", "n": i})
entries = client.get("/api/audit?limit=2").json()["entries"]
assert len(entries) == 2
assert entries[-1]["n"] == 4
# ─── Cost ──────────────────────────────────────────────────────────
def test_cost_empty_then_record(client):
assert client.get("/api/cost").json()["entries"] == []
client.post("/api/cost/record", json={"agent": "gemini", "tokens": 10, "cost": 0.0, "model": "flash"})
data = client.get("/api/cost").json()
assert data["entries"][0]["agent"] == "gemini"
assert data["entries"][0]["tokens"] == 10
# ─── Plugins ───────────────────────────────────────────────────────
def test_plugins_install_flow(client):
assert client.get("/api/plugins").json() == {"plugins": []}
assert client.post("/api/plugins/install", json={"name": "cool-plugin"}).json()["status"] == "installed"
assert client.post("/api/plugins/install", json={"name": "cool-plugin"}).json()["status"] == "already_installed"
assert client.post("/api/plugins/install", json={"name": ""}).status_code == 400
assert client.get("/api/plugins").json()["plugins"][0]["name"] == "cool-plugin"
# ─── Settings ──────────────────────────────────────────────────────
def test_settings_get_empty_and_update_merges(client, server_module):
assert client.get("/api/settings").json() == {}
client.put("/api/settings", json={"settings": {"theme": "dark"}})
client.put("/api/settings", json={"settings": {"port": 9000}})
result = client.get("/api/settings").json()
assert result == {"theme": "dark", "port": 9000}
# ─── Standards ─────────────────────────────────────────────────────
def test_standards_list_and_discover(client, server_module):
std = server_module.BASE_DIR / "standards"
(std / "naming.md").write_text("use snake_case")
(std / "index.yml").write_text("standards: [naming]")
body = client.get("/api/standards").json()
assert any(s["name"] == "naming" for s in body["standards"])
assert "snake_case" in body["standards"][0]["content"]
assert client.post("/api/standards/discover").json()["status"] == "discovery_started"
# ─── Prompts ───────────────────────────────────────────────────────
def test_prompts_list(client, server_module):
(server_module.BASE_DIR / "prompts" / "code-review.md").write_text("template body")
assert client.get("/api/prompts").json()["code-review"] == "template body"
# ─── Backups ───────────────────────────────────────────────────────
def test_backup_create_list_restore(client, server_module):
(server_module.BASE_DIR / "brain" / "memory.md").write_text("data")
created = client.post("/api/backup").json()
assert created["status"] == "ok"
listed = client.get("/api/backups").json()
assert any(b["name"] == created["file"] for b in listed)
assert client.post("/api/backup/restore", json={"file": created["file"]}).json()["status"] == "restored"
def test_restore_missing_backup_404(client):
assert client.post("/api/backup/restore", json={"file": "nope.tar.gz"}).status_code == 404
# ─── Chat ──────────────────────────────────────────────────────────
def test_chat_invalid_agent(client):
assert client.post("/api/chat", json={"agent": "bogus", "message": "hi"}).status_code == 400
def test_chat_records_history(client, server_module, monkeypatch):
monkeypatch.setattr(server_module, "execute_agent", lambda a, m: "hello from agent")
r = client.post("/api/chat", json={"agent": "Gemini", "message": "hi"})
assert r.status_code == 200
assert r.json()["response"]["content"] == "hello from agent"
history = client.get("/api/chat/history").json()["messages"]
assert history[0]["role"] == "user"
assert history[1]["role"] == "assistant"
# ─── Terminal ──────────────────────────────────────────────────────
def test_terminal_session_and_blank_command(client, server_module):
assert client.get("/api/terminal/session").json()["cwd"]
r = client.post("/api/terminal/run", json={"command": " "})
assert r.json()["returncode"] == 0
def test_terminal_cd_invalid_dir(client):
r = client.post("/api/terminal/run", json={"command": "cd /this/does/not/exist"})
assert r.json()["returncode"] == 1
assert "no such directory" in r.json()["stderr"]
def test_terminal_run_echo(client):
r = client.post("/api/terminal/run", json={"command": "echo hello-term"})
assert r.json()["returncode"] == 0
assert "hello-term" in r.json()["stdout"]
# ─── Goals ─────────────────────────────────────────────────────────
def test_goals_crud(client, server_module):
assert client.get("/api/goals").json() == {"goals": []}
created = client.post("/api/goals", json={"title": "Ship v1", "description": "launch"}).json()
gid = created["id"]
assert created["status"] == "active"
updated = client.put(f"/api/goals/{gid}", json={"progress": 50}).json()
assert updated["progress"] == 50
assert client.put("/api/goals/missing", json={"progress": 1}).status_code == 404
assert client.delete(f"/api/goals/{gid}").json() == {"status": "deleted"}
assert client.get("/api/goals").json() == {"goals": []}
def test_goal_creation_syncs_active_projects(client, server_module):
active = server_module.BASE_DIR / "brain" / "active-projects.md"
active.write_text("# Projects\n")
client.post("/api/goals", json={"title": "Docs", "description": "write docs"})
assert "Docs" in active.read_text()
# ─── Journal ───────────────────────────────────────────────────────
def test_journal_save_get_list_search(client, server_module):
assert client.get("/api/journal/entries").json() == {"entries": []}
client.put("/api/journal/entries/2026-07-08", json={"content": "Today I tested code"})
assert client.get("/api/journal/entries/2026-07-08").json()["content"] == "Today I tested code"
entries = client.get("/api/journal/entries").json()["entries"]
assert entries[0]["date"] == "2026-07-08"
found = client.get("/api/journal/search?q=tested").json()["results"]
assert found[0]["date"] == "2026-07-08"
assert client.get("/api/journal/search?q=").json() == {"results": []}
# ─── Agent health ──────────────────────────────────────────────────
def test_agent_health(client, server_module, monkeypatch):
monkeypatch.setattr(server_module.shutil, "which", lambda n: None)
body = client.get("/api/agents/health").json()
assert len(body["agents"]) == 3
assert body["agents"][0]["success_rate"] == 100
def test_agent_stats_valid_and_invalid(client, server_module, monkeypatch):
monkeypatch.setattr(server_module.shutil, "which", lambda n: None)
assert client.get("/api/agents/opencode/stats").json()["name"] == "opencode"
assert client.get("/api/agents/nope/stats").status_code == 400
def test_agent_health_refresh(client, server_module, monkeypatch):
monkeypatch.setattr(server_module.shutil, "which", lambda n: None)
assert len(client.post("/api/agents/health/refresh").json()["agents"]) == 3
# ─── Smart router ──────────────────────────────────────────────────
def test_router_suggest_high_confidence(client):
body = client.post("/api/router/suggest", json={"task": "deploy docker infra with terraform"}).json()
assert body["suggested_agent"] == "opencode"
assert body["confidence"] == "high"
def test_router_suggest_low_confidence(client):
body = client.post("/api/router/suggest", json={"task": "xyzzy"}).json()
assert body["confidence"] == "low"
def test_router_route_valid_and_invalid(client):
assert client.post("/api/router/route", json={"task": "t", "agent": "Hermes"}).json()["status"] == "routed"
assert client.post("/api/router/route", json={"task": "t", "agent": "bad"}).json()["status"] == "error"
# ─── Analytics ─────────────────────────────────────────────────────
def test_skill_analytics(client, server_module):
_make_skill(server_module, "code-review", scores=[{"score": 5}, {"score": 8}])
_make_skill(server_module, "brainstorming", scores=[])
body = client.get("/api/analytics/skills").json()["skills"]
cr = next(s for s in body if s["name"] == "code-review")
assert cr["total_runs"] == 2
assert cr["avg_score"] == 6.5
assert cr["trend"] == "up"
def test_trend_analytics(client, server_module):
_make_skill(server_module, "tdd-cycle", scores=[{"score": 3, "date": "d1"}])
body = client.get("/api/analytics/trends").json()["trends"]
assert body[0]["name"] == "tdd-cycle"
assert body[0]["scores"] == [3]
# ─── Session replay ────────────────────────────────────────────────
def test_sessions_list_empty(client, server_module, monkeypatch, tmp_path):
monkeypatch.setattr(server_module.Path, "home", staticmethod(lambda: tmp_path))
assert client.get("/api/sessions/list").json() == {"sessions": []}
def test_session_replay_not_found(client, server_module, monkeypatch, tmp_path):
monkeypatch.setattr(server_module.Path, "home", staticmethod(lambda: tmp_path))
body = client.get("/api/sessions/some-id/replay").json()
assert body["content"] == "Session log not found"

View File

@ -0,0 +1,147 @@
"""Unit tests for the pure helper functions in ``server.py``."""
import json
import pytest
def test_read_file_missing_returns_empty(server_module, tmp_path):
assert server_module.read_file(tmp_path / "nope.txt") == ""
def test_read_write_file_roundtrip(server_module, tmp_path):
target = tmp_path / "note.txt"
assert server_module.write_file(target, "hello") is True
assert server_module.read_file(target) == "hello"
def test_list_dir_missing_returns_empty(server_module, tmp_path):
assert server_module.list_dir(tmp_path / "absent") == []
def test_list_dir_skips_hidden_and_sorts(server_module, tmp_path):
d = tmp_path / "things"
d.mkdir()
(d / "b.txt").write_text("")
(d / "a.txt").write_text("")
(d / ".hidden").write_text("")
assert server_module.list_dir(d) == ["a.txt", "b.txt"]
def test_get_timestamp_is_iso_utc(server_module):
ts = server_module.get_timestamp()
# datetime.fromisoformat round-trips a valid ISO 8601 string.
from datetime import datetime
parsed = datetime.fromisoformat(ts)
assert parsed.tzinfo is not None
def test_append_audit_writes_entry(server_module):
server_module.append_audit({"action": "unit_test"})
audit_file = server_module.BASE_DIR / "audit" / "audit.log"
lines = audit_file.read_text().strip().splitlines()
assert len(lines) == 1
entry = json.loads(lines[0])
assert entry["action"] == "unit_test"
assert "timestamp" in entry
assert len(entry["id"]) == 8
def test_get_cors_origins_defaults(server_module, monkeypatch):
monkeypatch.delenv("AGENTIC_OS_CORS_ORIGINS", raising=False)
origins = server_module.get_cors_origins()
assert "http://localhost:8080" in origins
assert "http://127.0.0.1:8080" in origins
def test_get_cors_origins_reads_port_from_settings(server_module, monkeypatch):
monkeypatch.delenv("AGENTIC_OS_CORS_ORIGINS", raising=False)
settings = server_module.BASE_DIR / "data" / "settings.json"
settings.write_text(json.dumps({"dashboard": {"port": 9000}}))
origins = server_module.get_cors_origins()
assert "http://localhost:9000" in origins
assert "http://127.0.0.1:9000" in origins
def test_get_cors_origins_includes_env_extras(server_module, monkeypatch):
monkeypatch.setenv("AGENTIC_OS_CORS_ORIGINS", "https://a.example, https://b.example ")
origins = server_module.get_cors_origins()
assert "https://a.example" in origins
assert "https://b.example" in origins
def test_get_cors_origins_bad_settings_falls_back(server_module):
settings = server_module.BASE_DIR / "data" / "settings.json"
settings.write_text("{ not valid json")
origins = server_module.get_cors_origins()
assert "http://localhost:8080" in origins
@pytest.mark.parametrize("which_result,expected", [(None, "offline"), ("/usr/bin/opencode", "online")])
def test_check_agent_opencode(server_module, monkeypatch, which_result, expected):
monkeypatch.setattr(server_module.shutil, "which", lambda name: which_result)
assert server_module.check_agent("opencode") == {"name": "opencode", "status": expected}
def test_check_agent_unknown_is_offline(server_module):
assert server_module.check_agent("mystery")["status"] == "offline"
def test_check_agent_gemini_warning_when_not_logged_in(server_module, monkeypatch, tmp_path):
monkeypatch.setattr(server_module.shutil, "which", lambda name: "/usr/bin/gemini")
monkeypatch.setattr(server_module.Path, "home", staticmethod(lambda: tmp_path))
# No oauth creds file -> installed but not logged in -> warning.
assert server_module.check_agent("gemini")["status"] == "warning"
def test_check_agent_gemini_online_when_logged_in(server_module, monkeypatch, tmp_path):
monkeypatch.setattr(server_module.shutil, "which", lambda name: "/usr/bin/gemini")
monkeypatch.setattr(server_module.Path, "home", staticmethod(lambda: tmp_path))
creds = tmp_path / ".gemini" / "oauth_creds.json"
creds.parent.mkdir(parents=True)
creds.write_text('{"token": "ya29.abc"}')
assert server_module.check_agent("gemini")["status"] == "online"
def test_clean_hermes_output_empty(server_module):
assert server_module.clean_hermes_output("") == ""
def test_clean_hermes_output_extracts_box_content(server_module):
raw = "Query: hi\n╭─ box\nHello there\nSecond line\n╰─ end\nDuration: 1s"
assert server_module.clean_hermes_output(raw) == "Hello there\nSecond line"
def test_clean_hermes_output_fallback_without_box(server_module):
raw = "Query: hi\nInitializing...\nActual answer here"
assert "Actual answer here" in server_module.clean_hermes_output(raw)
def test_kanban_task_path_rejects_bad_id(server_module):
with pytest.raises(server_module.HTTPException):
server_module.kanban_task_path("../../etc/passwd")
def test_kanban_task_path_rejects_empty(server_module):
with pytest.raises(server_module.HTTPException):
server_module.kanban_task_path("")
def test_kanban_task_path_accepts_valid_id(server_module):
path = server_module.kanban_task_path("abc123")
assert path.name == "abc123.json"
assert path.parent == server_module.KANBAN_DIR.resolve()
def test_load_save_chat_history_roundtrip(server_module):
assert server_module.load_chat_history() == {"messages": []}
server_module.save_chat_message({"content": "hi"})
assert server_module.load_chat_history()["messages"][-1]["content"] == "hi"
def test_save_chat_message_caps_at_200(server_module):
for i in range(210):
server_module.save_chat_message({"content": str(i)})
history = server_module.load_chat_history()
assert len(history["messages"]) == 200
assert history["messages"][-1]["content"] == "209"

158
tests/test_server_kanban.py Normal file
View File

@ -0,0 +1,158 @@
"""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_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