"""Tests for bug tracker plugin."""
import json
from src.plugins.base import PluginBase
class TestGlobalDatabaseHelpers:
"""Tests for global database path and project ID helpers."""
def test_get_global_db_path_returns_home_based_path(self, monkeypatch):
"""Should return path under ~/.mcp-bugtracker/."""
from src.plugins.bugtracker import get_global_db_path
monkeypatch.setenv("HOME", "/Users/testuser")
path = get_global_db_path()
assert str(path) == "/Users/testuser/.mcp-bugtracker/bugs.db"
def test_compute_project_id_format(self):
"""Should return basename-hash8 format."""
from src.plugins.bugtracker import compute_project_id
project_id = compute_project_id("/Users/andy/my-project")
# Format: basename-8charhash
assert project_id.startswith("my-project-")
assert len(project_id) == len("my-project-") + 8
def test_compute_project_id_deterministic(self):
"""Same path should always produce same ID."""
from src.plugins.bugtracker import compute_project_id
id1 = compute_project_id("/Users/andy/my-project")
id2 = compute_project_id("/Users/andy/my-project")
assert id1 == id2
def test_compute_project_id_different_for_different_paths(self):
"""Different paths should produce different IDs."""
from src.plugins.bugtracker import compute_project_id
id1 = compute_project_id("/Users/andy/project1")
id2 = compute_project_id("/Users/andy/project2")
assert id1 != id2
def test_compute_project_id_same_basename_different_parents(self):
"""Same basename but different parents should produce different IDs."""
from src.plugins.bugtracker import compute_project_id
id1 = compute_project_id("/Users/andy/work/my-project")
id2 = compute_project_id("/Users/andy/personal/my-project")
# Both start with my-project but have different hashes
assert id1.startswith("my-project-")
assert id2.startswith("my-project-")
assert id1 != id2
class TestBugTrackerPluginInterface:
"""Tests for BugTrackerPlugin implementing PluginBase."""
def test_implements_plugin_interface(self):
"""Should implement PluginBase interface."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
assert isinstance(plugin, PluginBase)
def test_has_correct_name(self):
"""Should have correct plugin name."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
assert plugin.name == "bugtracker"
def test_has_version(self):
"""Should have a version string."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
assert plugin.version == "1.0.0"
def test_provides_tools(self):
"""Should provide bug tracking tools."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
tools = plugin.get_tools()
assert len(tools) >= 1
tool_names = [t.name for t in tools]
assert "init_bugtracker" in tool_names
def test_handles_unknown_tool(self):
"""Should return error for unknown tool."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
result = plugin.execute("unknown_tool", {})
assert result.is_error is True
assert "Unknown tool" in result.content[0]["text"]
class TestBugDataModels:
"""Tests for Bug, HistoryEntry, and RelatedBug data models."""
def test_related_bug_creation(self):
"""Should create RelatedBug with required fields."""
from src.plugins.bugtracker import RelatedBug
related = RelatedBug(bug_id="bug-123", relationship="duplicate_of")
assert related.bug_id == "bug-123"
assert related.relationship == "duplicate_of"
def test_related_bug_to_dict(self):
"""Should serialize RelatedBug to dictionary."""
from src.plugins.bugtracker import RelatedBug
related = RelatedBug(bug_id="bug-123", relationship="blocks")
data = related.to_dict()
assert data == {"bug_id": "bug-123", "relationship": "blocks"}
def test_related_bug_from_dict(self):
"""Should deserialize RelatedBug from dictionary."""
from src.plugins.bugtracker import RelatedBug
data = {"bug_id": "bug-456", "relationship": "related_to"}
related = RelatedBug.from_dict(data)
assert related.bug_id == "bug-456"
assert related.relationship == "related_to"
def test_history_entry_with_changes(self):
"""Should create HistoryEntry with field changes."""
from src.plugins.bugtracker import HistoryEntry
entry = HistoryEntry(
timestamp="2025-11-27T10:00:00Z",
changes={"status": ("open", "in_progress")},
note="Starting work",
)
assert entry.timestamp == "2025-11-27T10:00:00Z"
assert entry.changes == {"status": ("open", "in_progress")}
assert entry.note == "Starting work"
def test_history_entry_note_only(self):
"""Should create HistoryEntry with note but no field changes."""
from src.plugins.bugtracker import HistoryEntry
entry = HistoryEntry(
timestamp="2025-11-27T11:00:00Z",
changes={},
note="Tried approach X, didn't work. Trying Y now.",
)
assert entry.changes == {}
assert entry.note == "Tried approach X, didn't work. Trying Y now."
def test_history_entry_to_dict(self):
"""Should serialize HistoryEntry to dictionary."""
from src.plugins.bugtracker import HistoryEntry
entry = HistoryEntry(
timestamp="2025-11-27T10:00:00Z",
changes={"priority": ("low", "high")},
note="Escalating",
)
data = entry.to_dict()
assert data["timestamp"] == "2025-11-27T10:00:00Z"
assert data["changes"] == {"priority": ["low", "high"]}
assert data["note"] == "Escalating"
def test_history_entry_from_dict(self):
"""Should deserialize HistoryEntry from dictionary."""
from src.plugins.bugtracker import HistoryEntry
data = {
"timestamp": "2025-11-27T10:00:00Z",
"changes": {"status": ["open", "closed"]},
"note": "Fixed",
}
entry = HistoryEntry.from_dict(data)
assert entry.timestamp == "2025-11-27T10:00:00Z"
assert entry.changes == {"status": ("open", "closed")}
assert entry.note == "Fixed"
def test_bug_creation_minimal(self):
"""Should create Bug with minimal required fields."""
from src.plugins.bugtracker import Bug
bug = Bug(
id="bug-001",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title="Test bug",
description=None,
status="open",
priority="medium",
tags=[],
related_bugs=[],
created_at="2025-11-27T10:00:00Z",
history=[],
)
assert bug.id == "bug-001"
assert bug.title == "Test bug"
assert bug.status == "open"
def test_bug_creation_full(self):
"""Should create Bug with all fields populated."""
from src.plugins.bugtracker import Bug, HistoryEntry, RelatedBug
bug = Bug(
id="bug-002",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title="Complex bug",
description="Detailed description",
status="in_progress",
priority="critical",
tags=["backend", "urgent"],
related_bugs=[RelatedBug("bug-001", "blocks")],
created_at="2025-11-27T10:00:00Z",
history=[
HistoryEntry(
timestamp="2025-11-27T11:00:00Z",
changes={"status": ("open", "in_progress")},
note="Starting investigation",
)
],
)
assert bug.priority == "critical"
assert len(bug.tags) == 2
assert len(bug.related_bugs) == 1
assert len(bug.history) == 1
def test_bug_to_dict(self):
"""Should serialize Bug to dictionary."""
from src.plugins.bugtracker import Bug, HistoryEntry, RelatedBug
bug = Bug(
id="bug-003",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title="Serialization test",
description="Test desc",
status="open",
priority="low",
tags=["test"],
related_bugs=[RelatedBug("bug-001", "related_to")],
created_at="2025-11-27T10:00:00Z",
history=[
HistoryEntry(
timestamp="2025-11-27T10:00:00Z",
changes={},
note="Created",
)
],
)
data = bug.to_dict()
assert data["id"] == "bug-003"
assert data["title"] == "Serialization test"
assert data["tags"] == ["test"]
assert len(data["related_bugs"]) == 1
assert data["related_bugs"][0]["relationship"] == "related_to"
def test_bug_from_dict(self):
"""Should deserialize Bug from dictionary."""
from src.plugins.bugtracker import Bug
data = {
"id": "bug-004",
"project_id": "myproject-abc12345",
"project_path": "/path/to/myproject",
"title": "Deserialization test",
"description": None,
"status": "closed",
"priority": "high",
"tags": ["frontend"],
"related_bugs": [{"bug_id": "bug-002", "relationship": "duplicate_of"}],
"created_at": "2025-11-27T10:00:00Z",
"history": [
{
"timestamp": "2025-11-27T12:00:00Z",
"changes": {"status": ["open", "closed"]},
"note": "Resolved",
}
],
}
bug = Bug.from_dict(data)
assert bug.id == "bug-004"
assert bug.status == "closed"
assert len(bug.related_bugs) == 1
assert bug.related_bugs[0].relationship == "duplicate_of"
assert len(bug.history) == 1
def test_bug_json_roundtrip(self):
"""Should survive JSON serialization roundtrip."""
from src.plugins.bugtracker import Bug, HistoryEntry, RelatedBug
original = Bug(
id="bug-005",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title="Roundtrip test",
description="Testing JSON roundtrip",
status="in_progress",
priority="medium",
tags=["test", "json"],
related_bugs=[RelatedBug("bug-001", "blocks")],
created_at="2025-11-27T10:00:00Z",
history=[
HistoryEntry(
timestamp="2025-11-27T11:00:00Z",
changes={"status": ("open", "in_progress")},
note="Started",
)
],
)
json_str = json.dumps(original.to_dict())
restored = Bug.from_dict(json.loads(json_str))
assert restored.id == original.id
assert restored.title == original.title
assert restored.tags == original.tags
assert restored.related_bugs[0].bug_id == original.related_bugs[0].bug_id
assert restored.history[0].note == original.history[0].note
class TestBugStore:
"""Tests for BugStore SQLite storage layer."""
def test_create_store(self, tmp_path):
"""Should create a new BugStore instance."""
from src.plugins.bugtracker import BugStore
db_path = tmp_path / "bugs.db"
store = BugStore(db_path)
assert store is not None
store.close()
def test_initialize_creates_tables(self, tmp_path):
"""Should create bugs table on initialization."""
from src.plugins.bugtracker import BugStore
db_path = tmp_path / "bugs.db"
store = BugStore(db_path)
store.initialize()
# Verify table exists by attempting a query
bugs = store.list_bugs()
assert bugs == []
store.close()
def test_add_bug(self, tmp_path):
"""Should add a bug to the store."""
from src.plugins.bugtracker import Bug, BugStore
db_path = tmp_path / "bugs.db"
store = BugStore(db_path)
store.initialize()
bug = Bug(
id="bug-001",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title="Test bug",
description="A test",
status="open",
priority="medium",
tags=["test"],
related_bugs=[],
created_at="2025-11-27T10:00:00Z",
history=[],
)
store.add_bug(bug)
retrieved = store.get_bug("bug-001")
assert retrieved is not None
assert retrieved.title == "Test bug"
store.close()
def test_get_bug_not_found(self, tmp_path):
"""Should return None for non-existent bug."""
from src.plugins.bugtracker import BugStore
db_path = tmp_path / "bugs.db"
store = BugStore(db_path)
store.initialize()
result = store.get_bug("non-existent")
assert result is None
store.close()
def test_update_bug(self, tmp_path):
"""Should update an existing bug."""
from src.plugins.bugtracker import Bug, BugStore, HistoryEntry
db_path = tmp_path / "bugs.db"
store = BugStore(db_path)
store.initialize()
bug = Bug(
id="bug-001",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title="Original title",
description=None,
status="open",
priority="low",
tags=[],
related_bugs=[],
created_at="2025-11-27T10:00:00Z",
history=[],
)
store.add_bug(bug)
# Update the bug
bug.title = "Updated title"
bug.status = "in_progress"
bug.history.append(
HistoryEntry(
timestamp="2025-11-27T11:00:00Z",
changes={
"status": ("open", "in_progress"),
"title": ("Original title", "Updated title"),
},
note="Updated",
)
)
store.update_bug(bug)
retrieved = store.get_bug("bug-001")
assert retrieved.title == "Updated title"
assert retrieved.status == "in_progress"
assert len(retrieved.history) == 1
store.close()
def test_list_bugs_empty(self, tmp_path):
"""Should return empty list when no bugs."""
from src.plugins.bugtracker import BugStore
db_path = tmp_path / "bugs.db"
store = BugStore(db_path)
store.initialize()
bugs = store.list_bugs()
assert bugs == []
store.close()
def test_list_bugs_all(self, tmp_path):
"""Should return all bugs when no filter."""
from src.plugins.bugtracker import Bug, BugStore
db_path = tmp_path / "bugs.db"
store = BugStore(db_path)
store.initialize()
for i in range(3):
store.add_bug(
Bug(
id=f"bug-{i:03d}",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title=f"Bug {i}",
description=None,
status="open",
priority="medium",
tags=[],
related_bugs=[],
created_at="2025-11-27T10:00:00Z",
history=[],
)
)
bugs = store.list_bugs()
assert len(bugs) == 3
store.close()
def test_list_bugs_filter_status(self, tmp_path):
"""Should filter bugs by status."""
from src.plugins.bugtracker import Bug, BugStore
db_path = tmp_path / "bugs.db"
store = BugStore(db_path)
store.initialize()
store.add_bug(
Bug(
id="bug-001",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title="Open bug",
description=None,
status="open",
priority="low",
tags=[],
related_bugs=[],
created_at="2025-11-27T10:00:00Z",
history=[],
)
)
store.add_bug(
Bug(
id="bug-002",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title="Closed bug",
description=None,
status="closed",
priority="low",
tags=[],
related_bugs=[],
created_at="2025-11-27T10:00:00Z",
history=[],
)
)
open_bugs = store.list_bugs(status="open")
assert len(open_bugs) == 1
assert open_bugs[0].id == "bug-001"
store.close()
def test_list_bugs_filter_priority(self, tmp_path):
"""Should filter bugs by priority."""
from src.plugins.bugtracker import Bug, BugStore
db_path = tmp_path / "bugs.db"
store = BugStore(db_path)
store.initialize()
store.add_bug(
Bug(
id="bug-001",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title="Low bug",
description=None,
status="open",
priority="low",
tags=[],
related_bugs=[],
created_at="2025-11-27T10:00:00Z",
history=[],
)
)
store.add_bug(
Bug(
id="bug-002",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title="Critical bug",
description=None,
status="open",
priority="critical",
tags=[],
related_bugs=[],
created_at="2025-11-27T10:00:00Z",
history=[],
)
)
critical_bugs = store.list_bugs(priority="critical")
assert len(critical_bugs) == 1
assert critical_bugs[0].id == "bug-002"
store.close()
def test_list_bugs_filter_tags(self, tmp_path):
"""Should filter bugs by tags."""
from src.plugins.bugtracker import Bug, BugStore
db_path = tmp_path / "bugs.db"
store = BugStore(db_path)
store.initialize()
store.add_bug(
Bug(
id="bug-001",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title="Backend bug",
description=None,
status="open",
priority="low",
tags=["backend"],
related_bugs=[],
created_at="2025-11-27T10:00:00Z",
history=[],
)
)
store.add_bug(
Bug(
id="bug-002",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title="Frontend bug",
description=None,
status="open",
priority="low",
tags=["frontend", "ui"],
related_bugs=[],
created_at="2025-11-27T10:00:00Z",
history=[],
)
)
backend_bugs = store.list_bugs(tags=["backend"])
assert len(backend_bugs) == 1
assert backend_bugs[0].id == "bug-001"
store.close()
def test_bug_with_related_bugs_roundtrip(self, tmp_path):
"""Should store and retrieve bugs with related bugs."""
from src.plugins.bugtracker import Bug, BugStore, RelatedBug
db_path = tmp_path / "bugs.db"
store = BugStore(db_path)
store.initialize()
bug = Bug(
id="bug-001",
project_id="myproject-abc12345",
project_path="/path/to/myproject",
title="Primary bug",
description=None,
status="open",
priority="high",
tags=[],
related_bugs=[
RelatedBug("bug-002", "blocks"),
RelatedBug("bug-003", "related_to"),
],
created_at="2025-11-27T10:00:00Z",
history=[],
)
store.add_bug(bug)
retrieved = store.get_bug("bug-001")
assert len(retrieved.related_bugs) == 2
assert retrieved.related_bugs[0].bug_id == "bug-002"
assert retrieved.related_bugs[0].relationship == "blocks"
store.close()
def test_uses_wal_mode(self, tmp_path):
"""Should use WAL mode for better concurrency."""
import sqlite3
from src.plugins.bugtracker import BugStore
db_path = tmp_path / "bugs.db"
store = BugStore(db_path)
store.initialize()
store.close()
# Check WAL mode was set
conn = sqlite3.connect(db_path)
result = conn.execute("PRAGMA journal_mode;").fetchone()
conn.close()
assert result[0].lower() == "wal"
class TestInitBugtrackerTool:
"""Tests for init_bugtracker tool.
With global DB, init_bugtracker validates the path and returns project metadata.
It no longer creates per-project directories or rejects re-init.
"""
def test_init_returns_project_metadata(self, tmp_path, monkeypatch):
"""Should return project_id and database path on success."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
# Use temp home to avoid modifying real ~/.mcp-bugtracker
monkeypatch.setenv("HOME", str(tmp_path))
project = tmp_path / "myproject"
project.mkdir()
plugin = BugTrackerPlugin()
result = plugin.execute("init_bugtracker", {"project_path": str(project)})
assert result.is_error is False
data = json.loads(result.content[0]["text"])
assert data["status"] == "ready"
assert "project_id" in data
assert data["project_id"].startswith("myproject-")
assert data["project_path"] == str(project)
assert "database" in data
def test_init_creates_global_database(self, tmp_path, monkeypatch):
"""Should create global database at ~/.mcp-bugtracker/bugs.db."""
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
project = tmp_path / "myproject"
project.mkdir()
plugin = BugTrackerPlugin()
# Need to add a bug to trigger actual database creation (init just validates path)
plugin.execute("add_bug", {"title": "Test bug", "project_path": str(project)})
assert (tmp_path / ".mcp-bugtracker" / "bugs.db").exists()
def test_init_allows_reinit(self, tmp_path, monkeypatch):
"""Should allow multiple init calls (idempotent)."""
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
project = tmp_path / "myproject"
project.mkdir()
plugin = BugTrackerPlugin()
# First init
result1 = plugin.execute("init_bugtracker", {"project_path": str(project)})
assert result1.is_error is False
# Second init should also succeed (idempotent)
result2 = plugin.execute("init_bugtracker", {"project_path": str(project)})
assert result2.is_error is False
def test_init_handles_invalid_path(self, tmp_path):
"""Should handle invalid project path gracefully."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
result = plugin.execute("init_bugtracker", {"project_path": str(tmp_path / "nonexistent")})
assert result.is_error is True
assert "not exist" in result.content[0]["text"].lower()
def test_init_requires_project_path(self, tmp_path, monkeypatch):
"""Should require project_path argument or MCP_PROJECT_PATH env var."""
from src.plugins.bugtracker import BugTrackerPlugin
# Remove env var if set
monkeypatch.delenv("MCP_PROJECT_PATH", raising=False)
plugin = BugTrackerPlugin()
result = plugin.execute("init_bugtracker", {})
assert result.is_error is True
assert "project_path" in result.content[0]["text"].lower()
class TestAddBugTool:
"""Tests for add_bug tool."""
def test_add_bug_minimal(self, tmp_path):
"""Should add bug with just title."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
result = plugin.execute(
"add_bug",
{"title": "Test bug", "project_path": str(tmp_path)},
)
assert result.is_error is False
# Should return the bug ID
assert "bug-" in result.content[0]["text"].lower()
def test_add_bug_with_all_fields(self, tmp_path):
"""Should add bug with all optional fields."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
result = plugin.execute(
"add_bug",
{
"title": "Complex bug",
"description": "Detailed description",
"priority": "critical",
"tags": ["backend", "urgent"],
"project_path": str(tmp_path),
},
)
assert result.is_error is False
def test_add_bug_generates_uuid(self, tmp_path):
"""Should generate unique bug IDs."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
result1 = plugin.execute("add_bug", {"title": "Bug 1", "project_path": str(tmp_path)})
result2 = plugin.execute("add_bug", {"title": "Bug 2", "project_path": str(tmp_path)})
# Extract bug IDs from results
text1 = result1.content[0]["text"]
text2 = result2.content[0]["text"]
# IDs should be different
assert text1 != text2
def test_add_bug_defaults_to_open(self, tmp_path, monkeypatch):
"""Should default status to 'open'."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
project = tmp_path / "project"
project.mkdir()
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(project)})
add_result = plugin.execute("add_bug", {"title": "New bug", "project_path": str(project)})
bug_id = add_result.content[0]["text"].split(": ")[1]
# Verify via get_bug
get_result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(project)})
bug_data = json.loads(get_result.content[0]["text"])
assert bug_data["status"] == "open"
def test_add_bug_defaults_to_medium_priority(self, tmp_path, monkeypatch):
"""Should default priority to 'medium'."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
project = tmp_path / "project"
project.mkdir()
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(project)})
add_result = plugin.execute("add_bug", {"title": "New bug", "project_path": str(project)})
bug_id = add_result.content[0]["text"].split(": ")[1]
get_result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(project)})
bug_data = json.loads(get_result.content[0]["text"])
assert bug_data["priority"] == "medium"
def test_add_bug_works_without_explicit_init(self, tmp_path, monkeypatch):
"""Should work without explicit init (global DB auto-creates)."""
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
project = tmp_path / "project"
project.mkdir()
plugin = BugTrackerPlugin()
# Skip init, just add bug directly
result = plugin.execute("add_bug", {"title": "Bug", "project_path": str(project)})
assert result.is_error is False
assert "bug-" in result.content[0]["text"].lower()
def test_add_bug_requires_title(self, tmp_path):
"""Should fail if title not provided."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
result = plugin.execute("add_bug", {"project_path": str(tmp_path)})
assert result.is_error is True
assert "title" in result.content[0]["text"].lower()
def test_add_bug_records_created_at(self, tmp_path, monkeypatch):
"""Should record creation timestamp."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
project = tmp_path / "project"
project.mkdir()
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(project)})
add_result = plugin.execute("add_bug", {"title": "New bug", "project_path": str(project)})
bug_id = add_result.content[0]["text"].split(": ")[1]
get_result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(project)})
bug_data = json.loads(get_result.content[0]["text"])
assert bug_data["created_at"] is not None
assert "2025" in bug_data["created_at"] # Basic sanity check
class TestGetBugTool:
"""Tests for get_bug tool."""
def test_get_bug_returns_bug(self, tmp_path):
"""Should retrieve bug by ID."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
# Add a bug
add_result = plugin.execute(
"add_bug",
{"title": "Test bug", "description": "Test desc", "project_path": str(tmp_path)},
)
bug_id = add_result.content[0]["text"].split(": ")[1]
# Get the bug
result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(tmp_path)})
assert result.is_error is False
bug_data = json.loads(result.content[0]["text"])
assert bug_data["id"] == bug_id
assert bug_data["title"] == "Test bug"
def test_get_bug_not_found(self, tmp_path):
"""Should return error for non-existent bug."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
result = plugin.execute("get_bug", {"bug_id": "nonexistent", "project_path": str(tmp_path)})
assert result.is_error is True
assert "not found" in result.content[0]["text"].lower()
def test_get_bug_works_without_explicit_init(self, tmp_path, monkeypatch):
"""Should work without explicit init - just returns not found for missing bug."""
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
project = tmp_path / "project"
project.mkdir()
plugin = BugTrackerPlugin()
# Skip init, get bug directly - should return not found, not "not initialized"
result = plugin.execute("get_bug", {"bug_id": "bug-123", "project_path": str(project)})
assert result.is_error is True
assert "not found" in result.content[0]["text"].lower()
def test_get_bug_requires_bug_id(self, tmp_path):
"""Should fail if bug_id not provided."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
result = plugin.execute("get_bug", {"project_path": str(tmp_path)})
assert result.is_error is True
assert "bug_id" in result.content[0]["text"].lower()
class TestUpdateBugTool:
"""Tests for update_bug tool."""
def test_update_bug_status(self, tmp_path):
"""Should update bug status and record in history."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
# Add a bug
add_result = plugin.execute("add_bug", {"title": "Test bug", "project_path": str(tmp_path)})
bug_id = add_result.content[0]["text"].split(": ")[1]
# Update status
result = plugin.execute(
"update_bug",
{
"bug_id": bug_id,
"status": "in_progress",
"note": "Starting work",
"project_path": str(tmp_path),
},
)
assert result.is_error is False
# Verify history
get_result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(tmp_path)})
bug_data = json.loads(get_result.content[0]["text"])
assert bug_data["status"] == "in_progress"
assert len(bug_data["history"]) == 1
assert bug_data["history"][0]["changes"]["status"] == ["open", "in_progress"]
def test_update_bug_note_only(self, tmp_path):
"""Should allow note-only updates without field changes."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
add_result = plugin.execute(
"add_bug", {"title": "Complex bug", "project_path": str(tmp_path)}
)
bug_id = add_result.content[0]["text"].split(": ")[1]
# First update: change status
plugin.execute(
"update_bug",
{
"bug_id": bug_id,
"status": "in_progress",
"note": "Starting investigation",
"project_path": str(tmp_path),
},
)
# Second update: note only (tried something, didn't work)
result = plugin.execute(
"update_bug",
{
"bug_id": bug_id,
"note": "Tried approach X, didn't work. Trying Y now.",
"project_path": str(tmp_path),
},
)
assert result.is_error is False
get_result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(tmp_path)})
bug_data = json.loads(get_result.content[0]["text"])
# Should have 2 history entries
assert len(bug_data["history"]) == 2
# Second entry should have empty changes but a note
assert bug_data["history"][1]["changes"] == {}
assert "Tried approach X" in bug_data["history"][1]["note"]
def test_update_bug_priority(self, tmp_path):
"""Should update priority."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
add_result = plugin.execute("add_bug", {"title": "Bug", "project_path": str(tmp_path)})
bug_id = add_result.content[0]["text"].split(": ")[1]
plugin.execute(
"update_bug",
{
"bug_id": bug_id,
"priority": "critical",
"note": "Escalating",
"project_path": str(tmp_path),
},
)
get_result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(tmp_path)})
bug_data = json.loads(get_result.content[0]["text"])
assert bug_data["priority"] == "critical"
def test_update_bug_tags(self, tmp_path):
"""Should update tags."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
add_result = plugin.execute("add_bug", {"title": "Bug", "project_path": str(tmp_path)})
bug_id = add_result.content[0]["text"].split(": ")[1]
plugin.execute(
"update_bug",
{
"bug_id": bug_id,
"tags": ["backend", "urgent"],
"project_path": str(tmp_path),
},
)
get_result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(tmp_path)})
bug_data = json.loads(get_result.content[0]["text"])
assert bug_data["tags"] == ["backend", "urgent"]
def test_update_bug_related_bugs(self, tmp_path):
"""Should update related_bugs."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
# Create two bugs
add1 = plugin.execute("add_bug", {"title": "Bug 1", "project_path": str(tmp_path)})
bug1_id = add1.content[0]["text"].split(": ")[1]
add2 = plugin.execute("add_bug", {"title": "Bug 2", "project_path": str(tmp_path)})
bug2_id = add2.content[0]["text"].split(": ")[1]
# Mark bug2 as duplicate of bug1
plugin.execute(
"update_bug",
{
"bug_id": bug2_id,
"related_bugs": [{"bug_id": bug1_id, "relationship": "duplicate_of"}],
"note": "This is a duplicate",
"project_path": str(tmp_path),
},
)
get_result = plugin.execute("get_bug", {"bug_id": bug2_id, "project_path": str(tmp_path)})
bug_data = json.loads(get_result.content[0]["text"])
assert len(bug_data["related_bugs"]) == 1
assert bug_data["related_bugs"][0]["bug_id"] == bug1_id
assert bug_data["related_bugs"][0]["relationship"] == "duplicate_of"
def test_update_bug_reopen(self, tmp_path):
"""Should allow reopening a closed bug."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
add_result = plugin.execute("add_bug", {"title": "Bug", "project_path": str(tmp_path)})
bug_id = add_result.content[0]["text"].split(": ")[1]
# Close the bug
plugin.execute(
"update_bug",
{"bug_id": bug_id, "status": "closed", "note": "Fixed", "project_path": str(tmp_path)},
)
# Reopen the bug
plugin.execute(
"update_bug",
{
"bug_id": bug_id,
"status": "open",
"note": "Reopening - fix didn't work",
"project_path": str(tmp_path),
},
)
get_result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(tmp_path)})
bug_data = json.loads(get_result.content[0]["text"])
assert bug_data["status"] == "open"
assert len(bug_data["history"]) == 2
def test_update_bug_not_found(self, tmp_path):
"""Should return error for non-existent bug."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
result = plugin.execute(
"update_bug",
{"bug_id": "nonexistent", "status": "closed", "project_path": str(tmp_path)},
)
assert result.is_error is True
assert "not found" in result.content[0]["text"].lower()
def test_update_bug_requires_bug_id(self, tmp_path):
"""Should fail if bug_id not provided."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
result = plugin.execute("update_bug", {"status": "closed", "project_path": str(tmp_path)})
assert result.is_error is True
assert "bug_id" in result.content[0]["text"].lower()
class TestCloseBugTool:
"""Tests for close_bug tool (convenience wrapper)."""
def test_close_bug_sets_status_to_closed(self, tmp_path):
"""Should set bug status to closed."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
add_result = plugin.execute(
"add_bug", {"title": "Bug to close", "project_path": str(tmp_path)}
)
bug_id = add_result.content[0]["text"].split(": ")[1]
result = plugin.execute(
"close_bug",
{"bug_id": bug_id, "resolution": "Fixed the issue", "project_path": str(tmp_path)},
)
assert result.is_error is False
get_result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(tmp_path)})
bug_data = json.loads(get_result.content[0]["text"])
assert bug_data["status"] == "closed"
def test_close_bug_records_resolution_in_history(self, tmp_path):
"""Should record resolution note in history."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
add_result = plugin.execute("add_bug", {"title": "Bug", "project_path": str(tmp_path)})
bug_id = add_result.content[0]["text"].split(": ")[1]
plugin.execute(
"close_bug",
{
"bug_id": bug_id,
"resolution": "Deployed hotfix v2.1.3",
"project_path": str(tmp_path),
},
)
get_result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(tmp_path)})
bug_data = json.loads(get_result.content[0]["text"])
assert len(bug_data["history"]) == 1
assert "hotfix v2.1.3" in bug_data["history"][0]["note"]
def test_close_bug_requires_bug_id(self, tmp_path):
"""Should fail if bug_id not provided."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
result = plugin.execute("close_bug", {"resolution": "Fixed", "project_path": str(tmp_path)})
assert result.is_error is True
assert "bug_id" in result.content[0]["text"].lower()
def test_close_bug_not_found(self, tmp_path):
"""Should return error for non-existent bug."""
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
result = plugin.execute(
"close_bug",
{"bug_id": "nonexistent", "resolution": "Fixed", "project_path": str(tmp_path)},
)
assert result.is_error is True
assert "not found" in result.content[0]["text"].lower()
class TestListBugsTool:
"""Tests for list_bugs tool."""
def test_list_bugs_returns_all(self, tmp_path):
"""Should return all bugs when no filters provided."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
# Add 3 bugs
plugin.execute("add_bug", {"title": "Bug 1", "project_path": str(tmp_path)})
plugin.execute("add_bug", {"title": "Bug 2", "project_path": str(tmp_path)})
plugin.execute("add_bug", {"title": "Bug 3", "project_path": str(tmp_path)})
result = plugin.execute("list_bugs", {"project_path": str(tmp_path)})
assert result.is_error is False
bugs = json.loads(result.content[0]["text"])
assert len(bugs) == 3
def test_list_bugs_filter_by_status(self, tmp_path):
"""Should filter bugs by status."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
# Add bugs with different statuses
add1 = plugin.execute("add_bug", {"title": "Open bug", "project_path": str(tmp_path)})
bug1_id = add1.content[0]["text"].split(": ")[1]
add2 = plugin.execute(
"add_bug", {"title": "In progress bug", "project_path": str(tmp_path)}
)
bug2_id = add2.content[0]["text"].split(": ")[1]
plugin.execute(
"update_bug",
{"bug_id": bug2_id, "status": "in_progress", "project_path": str(tmp_path)},
)
add3 = plugin.execute("add_bug", {"title": "Closed bug", "project_path": str(tmp_path)})
bug3_id = add3.content[0]["text"].split(": ")[1]
plugin.execute("close_bug", {"bug_id": bug3_id, "project_path": str(tmp_path)})
# Filter by status=open
result = plugin.execute("list_bugs", {"status": "open", "project_path": str(tmp_path)})
bugs = json.loads(result.content[0]["text"])
assert len(bugs) == 1
assert bugs[0]["id"] == bug1_id
def test_list_bugs_filter_by_priority(self, tmp_path):
"""Should filter bugs by priority."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
plugin.execute(
"add_bug", {"title": "Low", "priority": "low", "project_path": str(tmp_path)}
)
plugin.execute(
"add_bug", {"title": "Critical", "priority": "critical", "project_path": str(tmp_path)}
)
plugin.execute("add_bug", {"title": "Medium", "project_path": str(tmp_path)}) # default
result = plugin.execute(
"list_bugs", {"priority": "critical", "project_path": str(tmp_path)}
)
bugs = json.loads(result.content[0]["text"])
assert len(bugs) == 1
assert bugs[0]["title"] == "Critical"
def test_list_bugs_filter_by_tags(self, tmp_path):
"""Should filter bugs by tags (must have all specified tags)."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
plugin.execute(
"add_bug",
{"title": "Backend only", "tags": ["backend"], "project_path": str(tmp_path)},
)
plugin.execute(
"add_bug",
{"title": "Frontend only", "tags": ["frontend"], "project_path": str(tmp_path)},
)
plugin.execute(
"add_bug",
{"title": "Both", "tags": ["backend", "frontend"], "project_path": str(tmp_path)},
)
# Filter by single tag
result = plugin.execute("list_bugs", {"tags": ["backend"], "project_path": str(tmp_path)})
bugs = json.loads(result.content[0]["text"])
assert len(bugs) == 2
# Filter by multiple tags (must have ALL)
result = plugin.execute(
"list_bugs", {"tags": ["backend", "frontend"], "project_path": str(tmp_path)}
)
bugs = json.loads(result.content[0]["text"])
assert len(bugs) == 1
assert bugs[0]["title"] == "Both"
def test_list_bugs_empty_result(self, tmp_path):
"""Should return empty list when no bugs match."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
result = plugin.execute("list_bugs", {"project_path": str(tmp_path)})
bugs = json.loads(result.content[0]["text"])
assert bugs == []
def test_list_bugs_combined_filters(self, tmp_path):
"""Should support combining status and priority filters."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(tmp_path)})
plugin.execute(
"add_bug", {"title": "A", "priority": "critical", "project_path": str(tmp_path)}
)
add2 = plugin.execute(
"add_bug", {"title": "B", "priority": "critical", "project_path": str(tmp_path)}
)
bug2_id = add2.content[0]["text"].split(": ")[1]
plugin.execute("close_bug", {"bug_id": bug2_id, "project_path": str(tmp_path)})
plugin.execute("add_bug", {"title": "C", "priority": "low", "project_path": str(tmp_path)})
# Critical AND open
result = plugin.execute(
"list_bugs",
{"status": "open", "priority": "critical", "project_path": str(tmp_path)},
)
bugs = json.loads(result.content[0]["text"])
assert len(bugs) == 1
assert bugs[0]["title"] == "A"
class TestProjectIndex:
"""Tests for project tracking with global database.
With global DB, projects are implicitly tracked through their bugs.
The old project index file is deprecated but the functions still exist.
"""
def test_global_db_tracks_projects_via_bugs(self, tmp_path, monkeypatch):
"""Projects are tracked in global DB via bug project_id/project_path."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
project1 = tmp_path / "project1"
project2 = tmp_path / "project2"
project1.mkdir()
project2.mkdir()
plugin = BugTrackerPlugin()
# Add bugs to each project
plugin.execute("add_bug", {"title": "Bug 1", "project_path": str(project1)})
plugin.execute("add_bug", {"title": "Bug 2", "project_path": str(project2)})
# Global search finds bugs from both projects
result = plugin.execute("search_bugs_global", {})
bugs = json.loads(result.content[0]["text"])
assert len(bugs) == 2
project_paths = {b["project_path"] for b in bugs}
assert str(project1) in project_paths
assert str(project2) in project_paths
def test_multiple_projects_isolated_by_project_id(self, tmp_path, monkeypatch):
"""Each project's bugs are isolated by project_id."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
project1 = tmp_path / "project1"
project2 = tmp_path / "project2"
project1.mkdir()
project2.mkdir()
plugin = BugTrackerPlugin()
# Add bugs to each project
plugin.execute("add_bug", {"title": "Project 1 Bug", "project_path": str(project1)})
plugin.execute("add_bug", {"title": "Project 2 Bug", "project_path": str(project2)})
# List bugs for project1 only
result = plugin.execute("list_bugs", {"project_path": str(project1)})
bugs = json.loads(result.content[0]["text"])
assert len(bugs) == 1
assert bugs[0]["title"] == "Project 1 Bug"
def test_deprecated_index_functions_still_work(self, tmp_path, monkeypatch):
"""The deprecated get_indexed_projects function still works."""
import json
from src.plugins.bugtracker import get_indexed_projects, get_project_index_path
monkeypatch.setenv("HOME", str(tmp_path))
# With no index file, returns empty list
projects = get_indexed_projects()
assert projects == []
# With index file containing projects, returns the list
index_path = get_project_index_path()
index_path.parent.mkdir(parents=True, exist_ok=True)
index_path.write_text(json.dumps({"projects": ["/path/to/project1", "/path/to/project2"]}))
projects = get_indexed_projects()
assert projects == ["/path/to/project1", "/path/to/project2"]
class TestSearchBugsGlobal:
"""Tests for search_bugs_global tool."""
def test_search_across_projects(self, tmp_path, monkeypatch):
"""Should search bugs across all indexed projects."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
project1 = tmp_path / "project1"
project2 = tmp_path / "project2"
project1.mkdir()
project2.mkdir()
plugin = BugTrackerPlugin()
# Init both projects
plugin.execute("init_bugtracker", {"project_path": str(project1)})
plugin.execute("init_bugtracker", {"project_path": str(project2)})
# Add bugs to each project
plugin.execute(
"add_bug",
{"title": "Auth bug", "tags": ["auth"], "project_path": str(project1)},
)
plugin.execute(
"add_bug",
{"title": "UI bug", "tags": ["frontend"], "project_path": str(project1)},
)
plugin.execute(
"add_bug",
{"title": "API auth issue", "tags": ["auth"], "project_path": str(project2)},
)
# Search for auth bugs across all projects
result = plugin.execute("search_bugs_global", {"tags": ["auth"]})
assert result.is_error is False
bugs = json.loads(result.content[0]["text"])
assert len(bugs) == 2
# Each result should have project_path
for bug in bugs:
assert "project_path" in bug
def test_search_with_status_filter(self, tmp_path, monkeypatch):
"""Should filter by status across projects."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
project = tmp_path / "project"
project.mkdir()
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(project)})
add1 = plugin.execute("add_bug", {"title": "Open bug", "project_path": str(project)})
_bug1_id = add1.content[0]["text"].split(": ")[1] # noqa: F841 - ID extracted to verify add works
plugin.execute("add_bug", {"title": "Another open", "project_path": str(project)})
add3 = plugin.execute("add_bug", {"title": "Closed bug", "project_path": str(project)})
bug3_id = add3.content[0]["text"].split(": ")[1]
plugin.execute("close_bug", {"bug_id": bug3_id, "project_path": str(project)})
result = plugin.execute("search_bugs_global", {"status": "open"})
bugs = json.loads(result.content[0]["text"])
assert len(bugs) == 2
def test_search_empty_result(self, tmp_path, monkeypatch):
"""Should return empty list when no bugs match."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
project = tmp_path / "project"
project.mkdir()
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(project)})
result = plugin.execute("search_bugs_global", {"status": "closed"})
bugs = json.loads(result.content[0]["text"])
assert bugs == []
def test_search_no_indexed_projects(self, tmp_path, monkeypatch):
"""Should return empty list when no projects indexed."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
plugin = BugTrackerPlugin()
result = plugin.execute("search_bugs_global", {})
assert result.is_error is False
bugs = json.loads(result.content[0]["text"])
assert bugs == []
class TestBugTrackerIntegration:
"""End-to-end integration tests for bug tracker workflow."""
def test_full_bug_lifecycle(self, tmp_path, monkeypatch):
"""Test complete bug lifecycle: create -> work -> close -> reopen."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
project = tmp_path / "project"
project.mkdir()
plugin = BugTrackerPlugin()
# 1. Initialize bug tracker (now just validates path, DB is global)
result = plugin.execute("init_bugtracker", {"project_path": str(project)})
assert result.is_error is False
# Global DB is at ~/.mcp-bugtracker/bugs.db, not per-project
# 2. Create a bug
result = plugin.execute(
"add_bug",
{
"title": "Authentication fails for OAuth users",
"description": "Users logging in via OAuth get 401 errors",
"priority": "high",
"tags": ["auth", "oauth", "backend"],
"project_path": str(project),
},
)
assert result.is_error is False
bug_id = result.content[0]["text"].split(": ")[1]
# 3. Verify bug was created correctly
result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(project)})
bug = json.loads(result.content[0]["text"])
assert bug["title"] == "Authentication fails for OAuth users"
assert bug["status"] == "open"
assert bug["priority"] == "high"
assert "oauth" in bug["tags"]
# 4. Start working on the bug
result = plugin.execute(
"update_bug",
{
"bug_id": bug_id,
"status": "in_progress",
"note": "Investigating token refresh logic",
"project_path": str(project),
},
)
assert result.is_error is False
# 5. Add progress note (no field changes)
result = plugin.execute(
"update_bug",
{
"bug_id": bug_id,
"note": "Found issue: token expiry check uses wrong timezone",
"project_path": str(project),
},
)
assert result.is_error is False
# 6. Close the bug
result = plugin.execute(
"close_bug",
{
"bug_id": bug_id,
"resolution": "Fixed timezone handling in token validation",
"project_path": str(project),
},
)
assert result.is_error is False
# 7. Verify full history
result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(project)})
bug = json.loads(result.content[0]["text"])
assert bug["status"] == "closed"
assert len(bug["history"]) == 3
# History entries: start work, progress note, close
assert bug["history"][0]["changes"]["status"] == ["open", "in_progress"]
assert "Investigating" in bug["history"][0]["note"]
assert bug["history"][1]["changes"] == {} # note-only update
assert "timezone" in bug["history"][1]["note"]
assert bug["history"][2]["changes"]["status"] == ["in_progress", "closed"]
# 8. Reopen the bug (regression found)
result = plugin.execute(
"update_bug",
{
"bug_id": bug_id,
"status": "open",
"note": "Regression: issue reappeared after DST change",
"project_path": str(project),
},
)
assert result.is_error is False
result = plugin.execute("get_bug", {"bug_id": bug_id, "project_path": str(project)})
bug = json.loads(result.content[0]["text"])
assert bug["status"] == "open"
assert len(bug["history"]) == 4
def test_multi_project_workflow(self, tmp_path, monkeypatch):
"""Test working with bugs across multiple projects."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
# Create two projects
backend = tmp_path / "backend"
frontend = tmp_path / "frontend"
backend.mkdir()
frontend.mkdir()
plugin = BugTrackerPlugin()
# Initialize both
plugin.execute("init_bugtracker", {"project_path": str(backend)})
plugin.execute("init_bugtracker", {"project_path": str(frontend)})
# Add bugs to each
plugin.execute(
"add_bug",
{"title": "API rate limiting", "tags": ["api"], "project_path": str(backend)},
)
plugin.execute(
"add_bug",
{"title": "Button alignment", "tags": ["ui"], "project_path": str(frontend)},
)
plugin.execute(
"add_bug",
{"title": "Auth token refresh", "tags": ["auth"], "project_path": str(backend)},
)
plugin.execute(
"add_bug",
{"title": "Login form", "tags": ["auth"], "project_path": str(frontend)},
)
# List bugs in backend only
result = plugin.execute("list_bugs", {"project_path": str(backend)})
bugs = json.loads(result.content[0]["text"])
assert len(bugs) == 2
# List bugs in frontend only
result = plugin.execute("list_bugs", {"project_path": str(frontend)})
bugs = json.loads(result.content[0]["text"])
assert len(bugs) == 2
# Search auth bugs globally
result = plugin.execute("search_bugs_global", {"tags": ["auth"]})
bugs = json.loads(result.content[0]["text"])
assert len(bugs) == 2
project_paths = {bug["project_path"] for bug in bugs}
assert str(backend) in project_paths
assert str(frontend) in project_paths
def test_related_bugs_workflow(self, tmp_path):
"""Test creating and linking related bugs."""
import json
from src.plugins.bugtracker import BugTrackerPlugin
project = tmp_path / "project"
project.mkdir()
plugin = BugTrackerPlugin()
plugin.execute("init_bugtracker", {"project_path": str(project)})
# Create original bug
result = plugin.execute(
"add_bug",
{"title": "Login page crashes", "project_path": str(project)},
)
original_id = result.content[0]["text"].split(": ")[1]
# Create duplicate
result = plugin.execute(
"add_bug",
{"title": "App crashes on login", "project_path": str(project)},
)
duplicate_id = result.content[0]["text"].split(": ")[1]
# Link as duplicate
plugin.execute(
"update_bug",
{
"bug_id": duplicate_id,
"related_bugs": [{"bug_id": original_id, "relationship": "duplicate_of"}],
"note": "Duplicate of original login crash bug",
"project_path": str(project),
},
)
# Close duplicate
plugin.execute(
"close_bug",
{"bug_id": duplicate_id, "resolution": "Duplicate", "project_path": str(project)},
)
# Verify
result = plugin.execute("get_bug", {"bug_id": duplicate_id, "project_path": str(project)})
bug = json.loads(result.content[0]["text"])
assert bug["status"] == "closed"
assert len(bug["related_bugs"]) == 1
assert bug["related_bugs"][0]["bug_id"] == original_id
assert bug["related_bugs"][0]["relationship"] == "duplicate_of"
class TestGetStoreNoneGuard:
"""Tests for defensive programming edge case.
NOTE: With the current implementation, _get_store() always returns a valid
BugStore instance. These tests verify that if we mock _get_store to return
None, the code will fail with AttributeError rather than silently.
This is acceptable because _get_store() is internal and guaranteed to
return a valid store. The tests document expected behavior if this
invariant is ever violated.
"""
def test_add_bug_crashes_if_store_is_none(self, tmp_path, monkeypatch):
"""Documents that add_bug will crash if _get_store returns None.
This is acceptable - _get_store is internal and always returns a store.
"""
import pytest
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
# Mock _get_store to return None (simulating broken invariant)
def mock_get_store():
return None
monkeypatch.setattr(plugin, "_get_store", mock_get_store)
# Expected to crash with AttributeError
with pytest.raises(AttributeError):
plugin.execute("add_bug", {"title": "Test", "project_path": str(tmp_path)})
def test_get_bug_crashes_if_store_is_none(self, tmp_path, monkeypatch):
"""Documents that get_bug will crash if _get_store returns None."""
import pytest
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
def mock_get_store():
return None
monkeypatch.setattr(plugin, "_get_store", mock_get_store)
with pytest.raises(AttributeError):
plugin.execute("get_bug", {"bug_id": "bug-123", "project_path": str(tmp_path)})
def test_update_bug_crashes_if_store_is_none(self, tmp_path, monkeypatch):
"""Documents that update_bug will crash if _get_store returns None."""
import pytest
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
def mock_get_store():
return None
monkeypatch.setattr(plugin, "_get_store", mock_get_store)
with pytest.raises(AttributeError):
plugin.execute(
"update_bug",
{"bug_id": "bug-123", "status": "in_progress", "project_path": str(tmp_path)},
)
def test_list_bugs_crashes_if_store_is_none(self, tmp_path, monkeypatch):
"""Documents that list_bugs will crash if _get_store returns None."""
import pytest
from src.plugins.bugtracker import BugTrackerPlugin
plugin = BugTrackerPlugin()
def mock_get_store():
return None
monkeypatch.setattr(plugin, "_get_store", mock_get_store)
with pytest.raises(AttributeError):
plugin.execute("list_bugs", {"project_path": str(tmp_path)})
class TestPathTraversalProtection:
"""Tests for path validation.
With global DB architecture, we validate that paths exist and are directories.
Path traversal protection is less strict since data is stored globally, not per-project.
"""
def test_init_rejects_path_traversal_attempt(self, tmp_path, monkeypatch):
"""Should reject project_path with path traversal to nonexistent directory."""
from src.plugins.bugtracker import BugTrackerPlugin
# Set cwd to a temp directory
monkeypatch.chdir(tmp_path)
(tmp_path / "allowed").mkdir()
plugin = BugTrackerPlugin()
# Path traversal to nonexistent directory should fail
result = plugin.execute(
"init_bugtracker",
{"project_path": "../../../nonexistent"},
)
assert result.is_error is True
assert (
"path" in result.content[0]["text"].lower()
or "exist" in result.content[0]["text"].lower()
)
def test_init_allows_any_existing_directory(self, tmp_path, monkeypatch):
"""Should allow any existing directory path (global DB architecture)."""
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
# Set cwd to a subdirectory
workdir = tmp_path / "workdir"
workdir.mkdir()
monkeypatch.chdir(workdir)
plugin = BugTrackerPlugin()
# Parent directory should be allowed with global DB
result = plugin.execute(
"init_bugtracker",
{"project_path": str(tmp_path)},
)
# With global DB, any valid existing directory is allowed
assert result.is_error is False
def test_add_bug_accepts_nonexistent_path(self, tmp_path, monkeypatch):
"""add_bug accepts nonexistent project path (path is metadata only).
Unlike init_bugtracker, add_bug doesn't validate path existence.
The path becomes metadata stored with the bug.
"""
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.chdir(tmp_path)
plugin = BugTrackerPlugin()
result = plugin.execute(
"add_bug",
{"title": "Test", "project_path": "../../../nonexistent"},
)
# Path is not validated in add_bug - it's just metadata
# The bug is created successfully
assert result.is_error is False
assert "bug-" in result.content[0]["text"].lower()
def test_init_allows_subdirectory(self, tmp_path, monkeypatch):
"""Should allow paths within the current working directory."""
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.chdir(tmp_path)
subdir = tmp_path / "projects" / "myproject"
subdir.mkdir(parents=True)
plugin = BugTrackerPlugin()
result = plugin.execute(
"init_bugtracker",
{"project_path": str(subdir)},
)
assert result.is_error is False
# With global DB, we create DB at ~/.mcp-bugtracker, not per-project
assert (tmp_path / ".mcp-bugtracker").exists() or result.is_error is False
def test_init_allows_relative_subdirectory(self, tmp_path, monkeypatch):
"""Should allow relative paths to subdirectories."""
from src.plugins.bugtracker import BugTrackerPlugin
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.chdir(tmp_path)
subdir = tmp_path / "projects"
subdir.mkdir()
plugin = BugTrackerPlugin()
result = plugin.execute(
"init_bugtracker",
{"project_path": "projects"},
)
assert result.is_error is False
# With global DB, we create DB at ~/.mcp-bugtracker, not per-project