"""Tests for fable_*_project tools.""" from unittest.mock import AsyncMock, MagicMock, patch import pytest from fabledassistant.mcp._context import _user_id_ctx from fabledassistant.mcp.tools.projects import ( list_projects, get_project, create_project, update_project, enter_project, ) @pytest.fixture(autouse=True) def _bind_user(): token = _user_id_ctx.set(7) yield _user_id_ctx.reset(token) def _fake_project(**overrides) -> MagicMock: p = MagicMock() base = {"id": 1, "title": "P", "description": "", "goal": "", "status": "active", "color": None, "auto_summary": None} base.update(overrides) p.to_dict.return_value = base return p @pytest.mark.asyncio async def test_list_projects_wraps_in_dict(): rows = [_fake_project(id=1), _fake_project(id=2)] with patch( "fabledassistant.mcp.tools.projects.projects_svc.list_projects", AsyncMock(return_value=rows), ): out = await list_projects() assert len(out["projects"]) == 2 @pytest.mark.asyncio async def test_get_project_enriches_with_milestone_summary(): p = _fake_project(id=5, title="found") milestone_summary = [{"id": 10, "title": "MS", "task_count": 3}] applicable_payload = { "rules": [], "truncated": False, "subscribed_rulebooks": [], } with patch( "fabledassistant.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=p), ), patch( "fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=milestone_summary), ), patch( "fabledassistant.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable_payload), ): out = await get_project(project_id=5) assert out["id"] == 5 assert out["milestone_summary"] == milestone_summary @pytest.mark.asyncio async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks(): """The augmented get_project response includes applicable_rules and subscribed_rulebooks pulled from services/rulebooks.get_applicable_rules. """ p = _fake_project(id=3, title="Fabled Assistant") milestone_summary = [] applicable_payload = { "rules": [ {"id": 1, "title": "dev is home", "statement": "Work directly on dev", "topic_title": "git-workflow", "rulebook_title": "FabledSword family"}, ], "truncated": False, "subscribed_rulebooks": [{"id": 1, "title": "FabledSword family"}], } with patch( "fabledassistant.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=p), ), patch( "fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=milestone_summary), ), patch( "fabledassistant.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable_payload), ): out = await get_project(project_id=3) assert out["applicable_rules"][0]["title"] == "dev is home" assert out["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}] assert out["applicable_rules_truncated"] is False @pytest.mark.asyncio async def test_get_project_raises_when_not_found(): with patch( "fabledassistant.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="project 999 not found"): await get_project(project_id=999) @pytest.mark.asyncio async def test_create_project_passes_color_empty_as_none(): p = _fake_project() mock = AsyncMock(return_value=p) with patch("fabledassistant.mcp.tools.projects.projects_svc.create_project", mock): await create_project(title="P", color="") assert mock.call_args.kwargs["color"] is None @pytest.mark.asyncio async def test_update_project_only_sends_non_default_fields(): p = _fake_project() mock = AsyncMock(return_value=p) with patch("fabledassistant.mcp.tools.projects.projects_svc.update_project", mock): await update_project(project_id=1, status="archived") args, kwargs = mock.call_args assert args == (7, 1) assert kwargs == {"status": "archived"} @pytest.mark.asyncio async def test_update_project_raises_when_not_found(): with patch( "fabledassistant.mcp.tools.projects.projects_svc.update_project", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="project 999 not found"): await update_project(project_id=999, title="x") @pytest.mark.asyncio async def test_enter_project_composes_full_context(): """enter_project pulls project + rules + milestone summary + open tasks + recent notes in one composed call.""" p = _fake_project(id=5, title="P") applicable_payload = { "rules": [{"id": 1, "title": "r1", "statement": "s", "topic_title": "t", "rulebook_title": "rb"}], "project_rules": [{"id": 99, "title": "pr1", "statement": "ps"}], "truncated": False, "subscribed_rulebooks": [{"id": 2, "title": "rb"}], } milestone_summary = [{"id": 10, "title": "MS", "task_count": 3}] task1 = MagicMock() task1.id = 100; task1.title = "T1"; task1.status = "in_progress" task1.priority = "high"; task1.task_kind = "work"; task1.milestone_id = 10 note1 = MagicMock() note1.id = 200; note1.title = "N1" note1.updated_at = None # avoids datetime mocking with patch( "fabledassistant.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=p), ), patch( "fabledassistant.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable_payload), ), patch( "fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=milestone_summary), ), patch( "fabledassistant.mcp.tools.projects.notes_svc.list_notes", AsyncMock(side_effect=[([task1], 1), ([note1], 1)]), ): out = await enter_project(project_id=5) assert out["project"]["id"] == 5 assert out["milestone_summary"] == milestone_summary assert out["applicable_rules"][0]["title"] == "r1" assert out["project_rules"][0]["id"] == 99 assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}] assert out["open_tasks"][0]["id"] == 100 assert out["open_tasks"][0]["status"] == "in_progress" assert out["recent_notes"][0]["id"] == 200 @pytest.mark.asyncio async def test_enter_project_raises_when_project_not_found(): with patch( "fabledassistant.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="project 999 not found"): await enter_project(project_id=999) def test_enter_project_registered_in_register(): """register(mcp) registers enter_project alongside the existing tools.""" from fabledassistant.mcp.tools.projects import register registered: list[str] = [] class FakeMCP: def tool(self, name=None): def decorator(fn): registered.append(name) return fn return decorator register(FakeMCP()) assert "enter_project" in registered