"""Unit tests for the write-time near-duplicate gate (services/dedup.py).""" from unittest.mock import AsyncMock, MagicMock, patch import pytest from scribe.services.dedup import ( DuplicateMatch, duplicate_response, find_duplicate_note, find_duplicate_rule, ) def _session_returning(note): """A mocked async_session() whose single execute() yields `note` (or None).""" s = AsyncMock() s.__aenter__ = AsyncMock(return_value=s) s.__aexit__ = AsyncMock(return_value=False) result = MagicMock() result.scalars.return_value.first.return_value = note s.execute = AsyncMock(return_value=result) return s def _fake_note(id=1, title="T", note_type="note"): n = MagicMock() n.id, n.title, n.note_type = id, title, note_type return n @pytest.mark.asyncio async def test_title_exact_match_returns_title_duplicate(): note = _fake_note(id=10, title="Setup CI") with patch("scribe.services.dedup.async_session", return_value=_session_returning(note)): # whitespace/case differences are normalized away dup = await find_duplicate_note(7, " setup ci ", project_id=2, is_task=True) assert dup is not None assert dup.id == 10 assert dup.reason == "title" assert dup.similarity == 1.0 @pytest.mark.asyncio async def test_short_body_skips_semantic_check(): sem = AsyncMock() with patch("scribe.services.dedup.async_session", return_value=_session_returning(None)), \ patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem): dup = await find_duplicate_note(7, "Unique", body="too short", project_id=2) assert dup is None sem.assert_not_called() # body under _MIN_BODY_FOR_SEMANTIC @pytest.mark.asyncio async def test_semantic_match_when_body_substantial(): hit = _fake_note(id=20, title="Existing", note_type="note") sem = AsyncMock(return_value=[(0.93, hit)]) with patch("scribe.services.dedup.async_session", return_value=_session_returning(None)), \ patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem): dup = await find_duplicate_note( 7, "Title", body="x" * 250, project_id=2, is_task=False, note_type="note", ) assert dup is not None assert dup.id == 20 assert dup.reason == "semantic" assert dup.similarity == 0.93 @pytest.mark.asyncio async def test_semantic_match_of_other_note_type_is_ignored(): other = _fake_note(id=21, title="X", note_type="process") sem = AsyncMock(return_value=[(0.97, other)]) with patch("scribe.services.dedup.async_session", return_value=_session_returning(None)), \ patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem): dup = await find_duplicate_note(7, "Title", body="x" * 250, note_type="note") assert dup is None # type mismatch must not block @pytest.mark.asyncio async def test_rule_title_match_in_topic(): rule = _fake_note(id=47, title="Honor the multi-user sharing ACL") with patch("scribe.services.dedup.async_session", return_value=_session_returning(rule)): dup = await find_duplicate_rule( "honor the multi-user sharing acl", topic_id=7, ) assert dup is not None assert dup.id == 47 assert dup.reason == "title" @pytest.mark.asyncio async def test_rule_requires_a_scope(): # No topic_id and no project_id → nothing to scope to → no match, no query. sess = AsyncMock() with patch("scribe.services.dedup.async_session", return_value=sess): dup = await find_duplicate_rule("anything") assert dup is None sess.__aenter__.assert_not_called() def test_duplicate_response_shape(): dm = DuplicateMatch(id=5, title="Foo", similarity=1.0, reason="title") r = duplicate_response(dm, "task") assert r["duplicate"] is True assert r["existing_id"] == 5 assert r["match"] == "title" assert "force=true" in r["message"] assert "update_task" in r["message"]