CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / integration (push) Successful in 55s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Successful in 1m34s
CI & Build / Build & push image (push) Successful in 27s
build_note (create_note and create_records) set the status and nothing it implies, so create_task(status="in_progress") wrote a started task with no started_at, and done/cancelled had no completed_at or next recurrence. The transition is now one function, apply_status_transition, called by both paths; tests pin the invariant for every status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
4.6 KiB
Python
107 lines
4.6 KiB
Python
"""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)
|