"""services/notes.py — the inline embedding moved here from the routes (#2056). Why it moved: embedding was triggered at each REST route and nowhere else, so a record created through MCP stayed out of semantic search and auto-inject until the next restart's backfill. Putting it in the service means every caller — REST, MCP, recurrence, snippets — gets it by construction rather than by remembering. These test the helper directly. The point of the change is that there is now ONE place to test. """ import pytest from scribe.services import notes as notes_svc # --- inline embedding (#2056) ----------------------------------------------- def test_embed_note_uses_the_OWNER_not_the_caller(): """LOAD-BEARING for shared records. An embedding belongs to the record; a collaborator editing a shared note must refresh the owner's row rather than mint a second one under their own id. The routes this replaced passed the caller's uid on one path and the owner's on another — exactly the kind of inconsistency that moving it to one place removes.""" from unittest.mock import MagicMock, patch note = MagicMock(id=5, user_id=42, title="T", body="B") with patch("scribe.services.embeddings.upsert_note_embedding") as upsert, \ patch("asyncio.create_task") as create_task: notes_svc.embed_note(note) assert create_task.called upsert.assert_called_once() assert upsert.call_args.args[0] == 5 assert upsert.call_args.args[1] == 42 # owner, never the caller # Title and body travel separately since #280 — chunking happens inside # upsert_note_embedding, the one path every writer shares. assert upsert.call_args.args[2] == "T" assert upsert.call_args.args[3] == "B" def test_embed_note_hands_even_an_empty_record_to_the_one_path(): """The empty-record decision moved INTO upsert_note_embedding (#280): an emptied record must have its stale vectors CLEARED, not merely skipped — so embed_note schedules the call unconditionally rather than deciding here. The clearing behaviour itself is pinned in test_chunking.py.""" from unittest.mock import MagicMock, patch note = MagicMock(id=5, user_id=42, title="", body="") with patch("scribe.services.embeddings.upsert_note_embedding") as upsert, \ patch("asyncio.create_task") as create_task: notes_svc.embed_note(note) assert create_task.called upsert.assert_called_once() def test_embed_note_without_a_running_loop_is_not_an_error(): """Unit tests and scripts call create_note with no event loop. That must be an ordinary case: a write that succeeded cannot be failed by its index refresh.""" from unittest.mock import MagicMock, patch note = MagicMock(id=5, user_id=42, title="T", body="B") with patch("asyncio.create_task", side_effect=RuntimeError("no running loop")): notes_svc.embed_note(note) # must not raise def test_embed_note_swallows_an_indexing_failure(): """Same reason, wider net: the embedding model being unavailable must not turn a successful save into a 500.""" from unittest.mock import MagicMock, patch note = MagicMock(id=5, user_id=42, title="T", body="B") with patch("asyncio.create_task", side_effect=ValueError("model gone")): notes_svc.embed_note(note) # must not raise # --- #3683: a create that names a status is the transition an update is ------ _LIFECYCLE = ("started_at", "completed_at", "recurrence_next_spawn_at") @pytest.mark.parametrize("status", ["todo", "in_progress", "done", "cancelled"]) def test_a_task_created_at_a_status_matches_one_updated_to_it(status): """The invariant, not the instance that surfaced it: which lifecycle stamps a row carries must not depend on whether it was created at a status or reached it by update. Recurrence is included because done/cancelled schedule the next spawn.""" rule = {"type": "interval", "unit": "week", "every": 1} created = notes_svc.build_note(1, title="t", status=status, recurrence_rule=rule) updated = notes_svc.build_note(1, title="t", recurrence_rule=rule) updated.status = status notes_svc.apply_status_transition(updated) for field in _LIFECYCLE: assert (getattr(created, field) is None) == (getattr(updated, field) is None), (status, field) def test_a_task_created_in_progress_knows_when_it_started(): note = notes_svc.build_note(1, title="t", status="in_progress") assert note.started_at is not None assert note.completed_at is None def test_a_note_with_no_status_gets_no_lifecycle_stamps(): note = notes_svc.build_note(1, title="t") assert all(getattr(note, f) is None for f in _LIFECYCLE)