forked from Rockachopa/Timmy-time-dashboard
Replace the stub `handle_bug_report` handler with a real implementation that logs a decision trail and dispatches code_fix tasks to Forge for automated fixing. Add `POST /api/bugs/submit` endpoint and `timmy ingest-report` CLI command so AI test runners (Comet) can submit structured bug reports without manual copy-paste. - POST /api/bugs/submit: accepts JSON reports, creates bug_report tasks - timmy ingest-report: CLI for file/stdin JSON ingestion with --dry-run - handle_bug_report: logs decision trail to event_log, dispatches code_fix task to Forge with parent_task_id linking back to the bug - 18 TDD tests covering endpoint, handler, and CLI Co-authored-by: Alexander Payne <apayne@MM.local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
337 lines
12 KiB
Python
337 lines
12 KiB
Python
"""Tests for bug report ingestion pipeline.
|
|
|
|
TDD — these tests are written FIRST, before the implementation.
|
|
Tests cover:
|
|
1. POST /api/bugs/submit endpoint
|
|
2. handle_bug_report handler with decision trail
|
|
3. CLI ingest-report command
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_db(tmp_path, monkeypatch):
|
|
"""Point task_queue and event_log SQLite to a temp directory."""
|
|
db = tmp_path / "swarm.db"
|
|
monkeypatch.setattr("swarm.task_queue.models.DB_PATH", db)
|
|
monkeypatch.setattr("swarm.event_log.DB_PATH", db)
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
from dashboard.app import app
|
|
|
|
with TestClient(app) as c:
|
|
yield c
|
|
|
|
|
|
# ── Sample data ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _sample_report(bugs=None):
|
|
"""Build a minimal test report."""
|
|
if bugs is None:
|
|
bugs = [{"title": "Test bug", "severity": "P1", "description": "Something broke"}]
|
|
return {"reporter": "comet", "bugs": bugs}
|
|
|
|
|
|
def _sample_bug(**overrides):
|
|
"""Build a single bug entry with defaults."""
|
|
bug = {
|
|
"title": "Widget crashes on click",
|
|
"severity": "P0",
|
|
"description": "Clicking the save button crashes the app",
|
|
}
|
|
bug.update(overrides)
|
|
return bug
|
|
|
|
|
|
# ── Test Group 1: Bug Submission Endpoint ────────────────────────────────
|
|
|
|
|
|
class TestBugSubmitEndpoint:
|
|
|
|
def test_submit_single_bug(self, client):
|
|
"""POST one bug creates one bug_report task."""
|
|
resp = client.post("/api/bugs/submit", json=_sample_report())
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["created"] == 1
|
|
assert len(data["task_ids"]) == 1
|
|
|
|
def test_submit_multiple_bugs(self, client):
|
|
"""POST 3 bugs creates 3 tasks."""
|
|
bugs = [
|
|
_sample_bug(title="Bug A", severity="P0"),
|
|
_sample_bug(title="Bug B", severity="P1"),
|
|
_sample_bug(title="Bug C", severity="P2"),
|
|
]
|
|
resp = client.post("/api/bugs/submit", json=_sample_report(bugs))
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["created"] == 3
|
|
assert len(data["task_ids"]) == 3
|
|
|
|
def test_submit_maps_severity_to_priority(self, client):
|
|
"""P0→urgent, P1→high, P2→normal."""
|
|
from swarm.task_queue.models import get_task
|
|
|
|
bugs = [
|
|
_sample_bug(title="P0 bug", severity="P0"),
|
|
_sample_bug(title="P1 bug", severity="P1"),
|
|
_sample_bug(title="P2 bug", severity="P2"),
|
|
]
|
|
resp = client.post("/api/bugs/submit", json=_sample_report(bugs))
|
|
data = resp.json()
|
|
|
|
tasks = [get_task(tid) for tid in data["task_ids"]]
|
|
priorities = {t.title: t.priority.value for t in tasks}
|
|
|
|
assert priorities["[P0] P0 bug"] == "urgent"
|
|
assert priorities["[P1] P1 bug"] == "high"
|
|
assert priorities["[P2] P2 bug"] == "normal"
|
|
|
|
def test_submit_formats_description(self, client):
|
|
"""Evidence, root_cause, fix_options appear in task description."""
|
|
from swarm.task_queue.models import get_task
|
|
|
|
bug = _sample_bug(
|
|
evidence="Console shows null pointer",
|
|
root_cause="Missing null check in handler",
|
|
fix_options=["Add guard clause", "Use optional chaining"],
|
|
)
|
|
resp = client.post("/api/bugs/submit", json=_sample_report([bug]))
|
|
data = resp.json()
|
|
|
|
task = get_task(data["task_ids"][0])
|
|
assert "Console shows null pointer" in task.description
|
|
assert "Missing null check" in task.description
|
|
assert "Add guard clause" in task.description
|
|
|
|
def test_submit_sets_task_type(self, client):
|
|
"""Created tasks have task_type='bug_report'."""
|
|
from swarm.task_queue.models import get_task
|
|
|
|
resp = client.post("/api/bugs/submit", json=_sample_report())
|
|
data = resp.json()
|
|
|
|
task = get_task(data["task_ids"][0])
|
|
assert task.task_type == "bug_report"
|
|
|
|
def test_submit_rejects_empty_bugs(self, client):
|
|
"""400 when bugs array is empty."""
|
|
resp = client.post("/api/bugs/submit", json=_sample_report(bugs=[]))
|
|
assert resp.status_code == 400
|
|
|
|
def test_submit_rejects_missing_fields(self, client):
|
|
"""400 when required fields are missing."""
|
|
resp = client.post("/api/bugs/submit", json={"reporter": "comet", "bugs": [{"title": "x"}]})
|
|
assert resp.status_code == 400
|
|
|
|
def test_submit_records_reporter(self, client):
|
|
"""Task created_by reflects the reporter."""
|
|
from swarm.task_queue.models import get_task
|
|
|
|
resp = client.post("/api/bugs/submit", json=_sample_report())
|
|
data = resp.json()
|
|
|
|
task = get_task(data["task_ids"][0])
|
|
assert task.created_by == "comet"
|
|
|
|
|
|
# ── Test Group 2: Bug Report Handler + Decision Trail ────────────────────
|
|
|
|
|
|
class TestBugReportHandler:
|
|
|
|
def _make_task(self, **overrides):
|
|
"""Create a real bug_report task in the queue."""
|
|
from swarm.task_queue.models import create_task
|
|
|
|
defaults = {
|
|
"title": "[P0] Widget crash",
|
|
"description": "The widget crashes when clicked",
|
|
"task_type": "bug_report",
|
|
"priority": "urgent",
|
|
"created_by": "comet",
|
|
}
|
|
defaults.update(overrides)
|
|
return create_task(**defaults)
|
|
|
|
def _get_handler(self):
|
|
"""Import the handle_bug_report function directly."""
|
|
from dashboard.app import handle_bug_report
|
|
|
|
return handle_bug_report
|
|
|
|
def test_handler_dispatches_fix_to_forge(self):
|
|
"""Handler creates a code_fix task assigned to Forge."""
|
|
from swarm.task_queue.models import get_task, list_tasks
|
|
|
|
handler = self._get_handler()
|
|
task = self._make_task()
|
|
result = handler(task)
|
|
|
|
# Should mention Forge dispatch
|
|
assert "Forge" in result
|
|
assert "Fix dispatched" in result
|
|
|
|
# A code_fix task should exist assigned to forge
|
|
all_tasks = list_tasks()
|
|
fix_tasks = [t for t in all_tasks if t.task_type == "code_fix" and t.assigned_to == "forge"]
|
|
assert len(fix_tasks) == 1
|
|
fix = fix_tasks[0]
|
|
assert fix.title == f"[Fix] {task.title}"
|
|
assert fix.created_by == "timmy"
|
|
assert fix.parent_task_id == task.id
|
|
|
|
def test_handler_logs_decision_to_event_log(self):
|
|
"""Handler logs a decision entry to the event log."""
|
|
handler = self._get_handler()
|
|
task = self._make_task()
|
|
handler(task)
|
|
|
|
from swarm.event_log import EventType, list_events
|
|
|
|
events = list_events(event_type=EventType.BUG_REPORT_CREATED, task_id=task.id)
|
|
assert len(events) >= 1
|
|
|
|
decision = json.loads(events[0].data)
|
|
assert decision["action"] == "dispatch_to_forge"
|
|
assert decision["outcome"] == "fix_dispatched"
|
|
assert "fix_task_id" in decision
|
|
|
|
def test_handler_fix_task_links_to_bug(self):
|
|
"""Fix task has parent_task_id pointing to the original bug."""
|
|
from swarm.task_queue.models import get_task
|
|
|
|
handler = self._get_handler()
|
|
task = self._make_task()
|
|
handler(task)
|
|
|
|
from swarm.event_log import EventType, list_events
|
|
|
|
events = list_events(event_type=EventType.BUG_REPORT_CREATED, task_id=task.id)
|
|
decision = json.loads(events[0].data)
|
|
fix_task = get_task(decision["fix_task_id"])
|
|
|
|
assert fix_task.parent_task_id == task.id
|
|
assert fix_task.task_type == "code_fix"
|
|
|
|
def test_handler_graceful_fallback_on_dispatch_failure(self):
|
|
"""When create_task fails, handler still returns a result and logs the error."""
|
|
handler = self._get_handler()
|
|
task = self._make_task() # Create task before patching
|
|
|
|
# Patch only the handler's dispatch call (re-imported inside the function body)
|
|
with patch("swarm.task_queue.models.create_task", side_effect=RuntimeError("db locked")):
|
|
result = handler(task)
|
|
|
|
assert result is not None
|
|
assert "dispatch failed" in result.lower()
|
|
|
|
from swarm.event_log import EventType, list_events
|
|
|
|
events = list_events(event_type=EventType.BUG_REPORT_CREATED, task_id=task.id)
|
|
assert len(events) >= 1
|
|
decision = json.loads(events[0].data)
|
|
assert decision["outcome"] == "dispatch_failed"
|
|
assert "db locked" in decision.get("error", "")
|
|
|
|
def test_handler_decision_includes_reason(self):
|
|
"""Decision dict always has action, reason, priority, outcome."""
|
|
handler = self._get_handler()
|
|
task = self._make_task()
|
|
handler(task)
|
|
|
|
from swarm.event_log import EventType, list_events
|
|
|
|
events = list_events(event_type=EventType.BUG_REPORT_CREATED, task_id=task.id)
|
|
decision = json.loads(events[0].data)
|
|
|
|
assert "action" in decision
|
|
assert "reason" in decision
|
|
assert "priority" in decision
|
|
assert "outcome" in decision
|
|
|
|
def test_handler_result_is_not_just_acknowledged(self):
|
|
"""task.result should contain structured info, not just 'acknowledged'."""
|
|
handler = self._get_handler()
|
|
task = self._make_task()
|
|
result = handler(task)
|
|
|
|
assert "acknowledged" not in result.lower() or "Decision" in result
|
|
|
|
|
|
# ── Test Group 3: CLI Command ────────────────────────────────────────────
|
|
|
|
|
|
class TestIngestReportCLI:
|
|
|
|
def test_cli_ingest_from_file(self, tmp_path):
|
|
"""CLI reads a JSON file and creates tasks."""
|
|
from typer.testing import CliRunner
|
|
from timmy.cli import app
|
|
|
|
report = _sample_report([
|
|
_sample_bug(title="CLI Bug A", severity="P1"),
|
|
_sample_bug(title="CLI Bug B", severity="P2"),
|
|
])
|
|
report_file = tmp_path / "report.json"
|
|
report_file.write_text(json.dumps(report))
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["ingest-report", str(report_file)])
|
|
|
|
assert result.exit_code == 0
|
|
assert "2" in result.stdout # 2 bugs created
|
|
|
|
def test_cli_dry_run(self, tmp_path):
|
|
"""--dry-run shows bugs but creates nothing."""
|
|
from typer.testing import CliRunner
|
|
from timmy.cli import app
|
|
from swarm.task_queue.models import list_tasks
|
|
|
|
report = _sample_report([_sample_bug(title="Dry Run Bug")])
|
|
report_file = tmp_path / "report.json"
|
|
report_file.write_text(json.dumps(report))
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["ingest-report", "--dry-run", str(report_file)])
|
|
|
|
assert result.exit_code == 0
|
|
assert "dry run" in result.stdout.lower()
|
|
|
|
# No tasks should have been created
|
|
tasks = list_tasks()
|
|
bug_tasks = [t for t in tasks if t.task_type == "bug_report" and "Dry Run" in t.title]
|
|
assert len(bug_tasks) == 0
|
|
|
|
def test_cli_invalid_json(self, tmp_path):
|
|
"""CLI exits with error on invalid JSON."""
|
|
from typer.testing import CliRunner
|
|
from timmy.cli import app
|
|
|
|
bad_file = tmp_path / "bad.json"
|
|
bad_file.write_text("not json {{{")
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["ingest-report", str(bad_file)])
|
|
|
|
assert result.exit_code != 0
|
|
|
|
def test_cli_missing_file(self):
|
|
"""CLI exits with error when file doesn't exist."""
|
|
from typer.testing import CliRunner
|
|
from timmy.cli import app
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["ingest-report", "/nonexistent/file.json"])
|
|
|
|
assert result.exit_code != 0
|