diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index 042cfff..d971029 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -409,6 +409,39 @@ async def convert_task_to_note(user_id: int, note_id: int) -> Note: return note +async def resolve_process(user_id: int, name_or_id) -> tuple[Note | None, list[dict]]: + """Resolve a stored process by id or name. + + Owner-scoped, note_type='process', non-trashed. Precedence: numeric id → + exact case-insensitive title → substring. Returns (note, other_candidates); + on a substring tie with no exact hit, `note` is the most-recently-updated + match and `other_candidates` lists the rest as [{id, title}] so the caller + can disambiguate. Returns (None, []) when nothing matches. + """ + async with async_session() as session: + base = select(Note).where( + Note.user_id == user_id, + Note.note_type == "process", + Note.deleted_at.is_(None), + ) + s = str(name_or_id).strip() + if s.isdigit(): + row = (await session.execute(base.where(Note.id == int(s)))).scalars().first() + if row is not None: + return row, [] + exact = (await session.execute( + base.where(func.lower(Note.title) == s.lower()).order_by(Note.updated_at.desc()) + )).scalars().first() + if exact is not None: + return exact, [] + matches = (await session.execute( + base.where(Note.title.ilike(f"%{s}%")).order_by(Note.updated_at.desc()) + )).scalars().all() + if not matches: + return None, [] + return matches[0], [{"id": n.id, "title": n.title} for n in matches[1:]] + + async def get_notes_by_ids(user_id: int, note_ids: list[int]) -> dict[int, Note]: """Batch fetch notes by ID list. Returns {note_id: Note}.""" if not note_ids: diff --git a/tests/test_services_notes_process.py b/tests/test_services_notes_process.py new file mode 100644 index 0000000..037d903 --- /dev/null +++ b/tests/test_services_notes_process.py @@ -0,0 +1,87 @@ +"""resolve_process precedence (id → exact title → substring) in services/notes.py. + +Mocks async_session — no real DB, matching the other notes-service tests. +""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +def _make_mock_session(): + s = AsyncMock() + s.__aenter__ = AsyncMock(return_value=s) + s.__aexit__ = AsyncMock(return_value=False) + return s + + +def _result(first=None, all_=None): + """A SQLAlchemy-result mock exposing .scalars().first()/.all().""" + r = MagicMock() + r.scalars.return_value.first.return_value = first + r.scalars.return_value.all.return_value = all_ or [] + return r + + +def _note(id, title): + n = MagicMock() + n.id = id + n.title = title + return n + + +@pytest.mark.asyncio +async def test_resolve_process_by_numeric_id(): + note = _note(5, "Drift Audit") + session = _make_mock_session() + # numeric id → first execute (id lookup) hits + session.execute = AsyncMock(side_effect=[_result(first=note)]) + with patch("fabledassistant.services.notes.async_session") as cls: + cls.return_value = session + from fabledassistant.services.notes import resolve_process + found, candidates = await resolve_process(1, "5") + assert found is note + assert candidates == [] + assert session.execute.await_count == 1 + + +@pytest.mark.asyncio +async def test_resolve_process_exact_title_beats_substring(): + note = _note(7, "Drift Audit") + session = _make_mock_session() + # non-digit → exact-title query (first execute) hits; substring never runs + session.execute = AsyncMock(side_effect=[_result(first=note)]) + with patch("fabledassistant.services.notes.async_session") as cls: + cls.return_value = session + from fabledassistant.services.notes import resolve_process + found, candidates = await resolve_process(1, "Drift Audit") + assert found is note + assert candidates == [] + assert session.execute.await_count == 1 + + +@pytest.mark.asyncio +async def test_resolve_process_substring_returns_candidates(): + n1 = _note(7, "Drift Audit Remediation") + n2 = _note(9, "Drift Audit Notes") + session = _make_mock_session() + # exact miss, then substring returns two (most-recent first) + session.execute = AsyncMock(side_effect=[_result(first=None), _result(all_=[n1, n2])]) + with patch("fabledassistant.services.notes.async_session") as cls: + cls.return_value = session + from fabledassistant.services.notes import resolve_process + found, candidates = await resolve_process(1, "drift") + assert found is n1 + assert candidates == [{"id": 9, "title": "Drift Audit Notes"}] + assert session.execute.await_count == 2 + + +@pytest.mark.asyncio +async def test_resolve_process_no_match(): + session = _make_mock_session() + session.execute = AsyncMock(side_effect=[_result(first=None), _result(all_=[])]) + with patch("fabledassistant.services.notes.async_session") as cls: + cls.return_value = session + from fabledassistant.services.notes import resolve_process + found, candidates = await resolve_process(1, "nope") + assert found is None + assert candidates == []