This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/tests/dashboard/test_bug_ingestion.py
Alexander Whitestone 6e67c3b421 feat: add bug report ingestion pipeline with Forge dispatch (#99)
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>
2026-02-28 21:15:53 -05:00

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