"""services/notes.py — inline embedding lives in the service. Embedding triggered at each REST route and nowhere else would leave a record created through MCP out of semantic search and auto-inject until the next restart's backfill. In the service, every caller — REST, MCP, recurrence, snippets — gets it by construction rather than by remembering. These test the helper directly: there is ONE place to test. """ import pytest from scribe.services import notes as notes_svc # --- inline embedding ------------------------------------------------------- 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 — 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 lives IN upsert_note_embedding: 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 # --- 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)