"""Tests for fable_*_task tools.""" from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest from fabledassistant.mcp._context import _user_id_ctx from fabledassistant.mcp.tools.tasks import ( list_tasks, get_task, create_task, update_task, add_task_log, ) @pytest.fixture(autouse=True) def _bind_user(): token = _user_id_ctx.set(7) yield _user_id_ctx.reset(token) def _fake_task(*, parent_id: int | None = None, **overrides) -> MagicMock: n = MagicMock() n.parent_id = parent_id base = { "id": 1, "title": "t", "body": "", "status": "todo", "priority": "none", "tags": [], "parent_id": parent_id, "is_task": True, } base.update(overrides) n.to_dict.return_value = base n.title = base["title"] return n @pytest.mark.asyncio async def test_list_tasks_passes_is_task_true_and_repackages(): rows = [_fake_task(id=1), _fake_task(id=2)] mock = AsyncMock(return_value=(rows, 2)) with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock): out = await list_tasks() assert out["total"] == 2 assert len(out["tasks"]) == 2 assert mock.call_args.kwargs["is_task"] is True @pytest.mark.asyncio async def test_list_tasks_status_filter(): mock = AsyncMock(return_value=([], 0)) with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock): await list_tasks(status="in_progress") assert mock.call_args.kwargs["status"] == "in_progress" @pytest.mark.asyncio async def test_list_tasks_empty_status_means_no_filter(): mock = AsyncMock(return_value=([], 0)) with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock): await list_tasks(status="") assert mock.call_args.kwargs["status"] is None @pytest.mark.asyncio async def test_get_task_with_no_parent_returns_null_parent_title(): fake = _fake_task(id=5, title="solo", parent_id=None) with patch( "fabledassistant.mcp.tools.tasks.notes_svc.get_note", AsyncMock(return_value=fake), ): out = await get_task(task_id=5) assert out["parent_title"] is None assert out["title"] == "solo" @pytest.mark.asyncio async def test_get_task_enriches_with_parent_title(): """When parent_id is set, get_task fetches the parent and adds parent_title.""" child = _fake_task(id=10, title="child", parent_id=5) parent = _fake_task(id=5, title="parent of 10", parent_id=None) # get_note is called twice: once for child, once for parent mock_get = AsyncMock(side_effect=[child, parent]) with patch("fabledassistant.mcp.tools.tasks.notes_svc.get_note", mock_get): out = await get_task(task_id=10) assert out["parent_title"] == "parent of 10" assert mock_get.await_count == 2 @pytest.mark.asyncio async def test_get_task_parent_missing_returns_null(): """If parent_id is set but the parent is gone (orphaned), parent_title is None.""" child = _fake_task(id=10, parent_id=5) mock_get = AsyncMock(side_effect=[child, None]) with patch("fabledassistant.mcp.tools.tasks.notes_svc.get_note", mock_get): out = await get_task(task_id=10) assert out["parent_title"] is None @pytest.mark.asyncio async def test_get_task_raises_when_not_found(): with patch( "fabledassistant.mcp.tools.tasks.notes_svc.get_note", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="task 999 not found"): await get_task(task_id=999) @pytest.mark.asyncio async def test_create_task_passes_status(): fake = _fake_task() mock = AsyncMock(return_value=fake) with patch("fabledassistant.mcp.tools.tasks.notes_svc.create_note", mock): await create_task(title="do x", status="todo") assert mock.call_args.kwargs["status"] == "todo" @pytest.mark.asyncio async def test_create_task_priority_empty_becomes_none(): fake = _fake_task() mock = AsyncMock(return_value=fake) with patch("fabledassistant.mcp.tools.tasks.notes_svc.create_note", mock): await create_task(title="x", priority="") assert mock.call_args.kwargs["priority"] is None @pytest.mark.asyncio async def test_create_task_zero_id_sentinels_become_none(): fake = _fake_task() mock = AsyncMock(return_value=fake) with patch("fabledassistant.mcp.tools.tasks.notes_svc.create_note", mock): await create_task(title="x", project_id=0, milestone_id=0, parent_id=0) assert mock.call_args.kwargs["project_id"] is None assert mock.call_args.kwargs["milestone_id"] is None assert mock.call_args.kwargs["parent_id"] is None @pytest.mark.asyncio async def test_update_task_only_sends_non_default_fields(): fake = _fake_task() mock = AsyncMock(return_value=fake) with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock): await update_task(task_id=1, status="done") args, kwargs = mock.call_args assert args == (7, 1) assert kwargs == {"status": "done"} @pytest.mark.asyncio async def test_update_task_empty_priority_is_omitted(): """Priority="" is "leave unchanged" — must not reach service as empty string.""" fake = _fake_task() mock = AsyncMock(return_value=fake) with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock): await update_task(task_id=1, status="done", priority="") assert "priority" not in mock.call_args.kwargs @pytest.mark.asyncio async def test_update_task_raises_when_not_found(): with patch( "fabledassistant.mcp.tools.tasks.notes_svc.update_note", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="task 999 not found"): await update_task(task_id=999, status="done") @pytest.mark.asyncio async def test_add_task_log_returns_log_dict(): log = MagicMock() log.id = 5 log.task_id = 1 log.content = "checkpoint" log.created_at = datetime(2026, 5, 26, 12, tzinfo=timezone.utc) # No to_dict on TaskLog model (per task_logs.py inspection) — fall through to manual dict del log.to_dict with patch( "fabledassistant.mcp.tools.tasks.task_logs_svc.create_log", AsyncMock(return_value=log), ): out = await add_task_log(task_id=1, content="checkpoint") assert out["id"] == 5 assert out["task_id"] == 1 assert out["content"] == "checkpoint" assert out["created_at"].startswith("2026-05-26") @pytest.mark.asyncio async def test_add_task_log_propagates_value_error_when_task_missing(): """create_log raises ValueError if the task doesn't exist; we let it through.""" with patch( "fabledassistant.mcp.tools.tasks.task_logs_svc.create_log", AsyncMock(side_effect=ValueError("Task 999 not found")), ): with pytest.raises(ValueError): await add_task_log(task_id=999, content="x")