1+ """Tests for the search repository edit bug fix.
2+
3+ This test reproduces the critical bug where editing notes causes them to disappear
4+ from the search index due to missing project_id filter in index_item() method.
5+ """
6+
7+ from datetime import datetime , timezone
8+
9+ import pytest
10+ import pytest_asyncio
11+
12+ from basic_memory .models import Entity
13+ from basic_memory .models .project import Project
14+ from basic_memory .repository .search_repository import SearchRepository , SearchIndexRow
15+ from basic_memory .schemas .search import SearchItemType
16+
17+
18+ @pytest_asyncio .fixture
19+ async def second_test_project (project_repository ):
20+ """Create a second project for testing project isolation during edits."""
21+ project_data = {
22+ "name" : "Second Edit Test Project" ,
23+ "description" : "Another project for testing edit bug" ,
24+ "path" : "/second/edit/test/path" ,
25+ "is_active" : True ,
26+ "is_default" : None ,
27+ }
28+ return await project_repository .create (project_data )
29+
30+
31+ @pytest_asyncio .fixture
32+ async def second_search_repo (session_maker , second_test_project ):
33+ """Create a search repository for the second project."""
34+ return SearchRepository (session_maker , project_id = second_test_project .id )
35+
36+
37+ @pytest .mark .asyncio
38+ async def test_index_item_respects_project_isolation_during_edit ():
39+ """Test that index_item() doesn't delete records from other projects during edits.
40+
41+ This test reproduces the critical bug where editing a note in one project
42+ would delete search index entries with the same permalink from ALL projects,
43+ causing notes to disappear from the search index.
44+ """
45+ from basic_memory import db
46+ from basic_memory .models .project import Project
47+ from basic_memory .repository .search_repository import SearchRepository
48+ from sqlalchemy .ext .asyncio import create_async_engine , async_sessionmaker
49+
50+ # Create a separate in-memory database for this test
51+ engine = create_async_engine ("sqlite+aiosqlite:///:memory:" )
52+ session_maker = async_sessionmaker (engine , expire_on_commit = False )
53+
54+ # Create the database schema
55+ async with engine .begin () as conn :
56+ await conn .run_sync (db .Base .metadata .create_all )
57+
58+ # Create two projects
59+ async with db .scoped_session (session_maker ) as session :
60+ project1 = Project (
61+ name = "Project 1" ,
62+ description = "First project" ,
63+ path = "/project1/path" ,
64+ is_active = True ,
65+ is_default = True
66+ )
67+ project2 = Project (
68+ name = "Project 2" ,
69+ description = "Second project" ,
70+ path = "/project2/path" ,
71+ is_active = True ,
72+ is_default = False
73+ )
74+ session .add (project1 )
75+ session .add (project2 )
76+ await session .flush ()
77+
78+ project1_id = project1 .id
79+ project2_id = project2 .id
80+ await session .commit ()
81+
82+ # Create search repositories for both projects
83+ repo1 = SearchRepository (session_maker , project_id = project1_id )
84+ repo2 = SearchRepository (session_maker , project_id = project2_id )
85+
86+ # Initialize search index
87+ await repo1 .init_search_index ()
88+
89+ # Create two notes with the SAME permalink in different projects
90+ # This simulates the same note name/structure across different projects
91+ same_permalink = "notes/test-note"
92+
93+ search_row1 = SearchIndexRow (
94+ id = 1 ,
95+ type = SearchItemType .ENTITY .value ,
96+ title = "Test Note in Project 1" ,
97+ content_stems = "project 1 content original" ,
98+ content_snippet = "This is the original content in project 1" ,
99+ permalink = same_permalink ,
100+ file_path = "notes/test_note.md" ,
101+ entity_id = 1 ,
102+ metadata = {"entity_type" : "note" },
103+ created_at = datetime .now (timezone .utc ),
104+ updated_at = datetime .now (timezone .utc ),
105+ project_id = project1_id ,
106+ )
107+
108+ search_row2 = SearchIndexRow (
109+ id = 2 ,
110+ type = SearchItemType .ENTITY .value ,
111+ title = "Test Note in Project 2" ,
112+ content_stems = "project 2 content original" ,
113+ content_snippet = "This is the original content in project 2" ,
114+ permalink = same_permalink , # SAME permalink as project 1
115+ file_path = "notes/test_note.md" ,
116+ entity_id = 2 ,
117+ metadata = {"entity_type" : "note" },
118+ created_at = datetime .now (timezone .utc ),
119+ updated_at = datetime .now (timezone .utc ),
120+ project_id = project2_id ,
121+ )
122+
123+ # Index both items in their respective projects
124+ await repo1 .index_item (search_row1 )
125+ await repo2 .index_item (search_row2 )
126+
127+ # Verify both projects can find their respective notes
128+ results1_before = await repo1 .search (search_text = "project 1 content" )
129+ assert len (results1_before ) == 1
130+ assert results1_before [0 ].title == "Test Note in Project 1"
131+ assert results1_before [0 ].project_id == project1_id
132+
133+ results2_before = await repo2 .search (search_text = "project 2 content" )
134+ assert len (results2_before ) == 1
135+ assert results2_before [0 ].title == "Test Note in Project 2"
136+ assert results2_before [0 ].project_id == project2_id
137+
138+ # Now simulate editing the note in project 1 (which re-indexes it)
139+ # This would trigger the bug where the DELETE query doesn't filter by project_id
140+ edited_search_row1 = SearchIndexRow (
141+ id = 1 ,
142+ type = SearchItemType .ENTITY .value ,
143+ title = "Test Note in Project 1" ,
144+ content_stems = "project 1 content EDITED" , # Changed content
145+ content_snippet = "This is the EDITED content in project 1" ,
146+ permalink = same_permalink ,
147+ file_path = "notes/test_note.md" ,
148+ entity_id = 1 ,
149+ metadata = {"entity_type" : "note" },
150+ created_at = datetime .now (timezone .utc ),
151+ updated_at = datetime .now (timezone .utc ),
152+ project_id = project1_id ,
153+ )
154+
155+ # Re-index the edited note in project 1
156+ # BEFORE THE FIX: This would delete the note from project 2 as well!
157+ await repo1 .index_item (edited_search_row1 )
158+
159+ # Verify project 1 has the edited version
160+ results1_after = await repo1 .search (search_text = "project 1 content EDITED" )
161+ assert len (results1_after ) == 1
162+ assert results1_after [0 ].title == "Test Note in Project 1"
163+ assert "EDITED" in results1_after [0 ].content_snippet
164+
165+ # CRITICAL TEST: Verify project 2's note is still there (the bug would delete it)
166+ results2_after = await repo2 .search (search_text = "project 2 content" )
167+ assert len (results2_after ) == 1 , "Project 2's note disappeared after editing project 1's note!"
168+ assert results2_after [0 ].title == "Test Note in Project 2"
169+ assert results2_after [0 ].project_id == project2_id
170+ assert "original" in results2_after [0 ].content_snippet # Should still be original
171+
172+ # Double-check: project 1 should not be able to see project 2's note
173+ cross_search = await repo1 .search (search_text = "project 2 content" )
174+ assert len (cross_search ) == 0
175+
176+ await engine .dispose ()
177+
178+
179+ @pytest .mark .asyncio
180+ async def test_index_item_updates_existing_record_same_project ():
181+ """Test that index_item() correctly updates existing records within the same project."""
182+ from basic_memory import db
183+ from basic_memory .models .project import Project
184+ from basic_memory .repository .search_repository import SearchRepository
185+ from sqlalchemy .ext .asyncio import create_async_engine , async_sessionmaker
186+
187+ # Create a separate in-memory database for this test
188+ engine = create_async_engine ("sqlite+aiosqlite:///:memory:" )
189+ session_maker = async_sessionmaker (engine , expire_on_commit = False )
190+
191+ # Create the database schema
192+ async with engine .begin () as conn :
193+ await conn .run_sync (db .Base .metadata .create_all )
194+
195+ # Create one project
196+ async with db .scoped_session (session_maker ) as session :
197+ project = Project (
198+ name = "Test Project" ,
199+ description = "Test project" ,
200+ path = "/test/path" ,
201+ is_active = True ,
202+ is_default = True
203+ )
204+ session .add (project )
205+ await session .flush ()
206+ project_id = project .id
207+ await session .commit ()
208+
209+ # Create search repository
210+ repo = SearchRepository (session_maker , project_id = project_id )
211+ await repo .init_search_index ()
212+
213+ permalink = "test/my-note"
214+
215+ # Create initial note
216+ initial_row = SearchIndexRow (
217+ id = 1 ,
218+ type = SearchItemType .ENTITY .value ,
219+ title = "My Test Note" ,
220+ content_stems = "initial content here" ,
221+ content_snippet = "This is the initial content" ,
222+ permalink = permalink ,
223+ file_path = "test/my_note.md" ,
224+ entity_id = 1 ,
225+ metadata = {"entity_type" : "note" },
226+ created_at = datetime .now (timezone .utc ),
227+ updated_at = datetime .now (timezone .utc ),
228+ project_id = project_id ,
229+ )
230+
231+ # Index the initial version
232+ await repo .index_item (initial_row )
233+
234+ # Verify it exists
235+ results_initial = await repo .search (search_text = "initial content" )
236+ assert len (results_initial ) == 1
237+ assert results_initial [0 ].content_snippet == "This is the initial content"
238+
239+ # Now update the note (simulate an edit)
240+ updated_row = SearchIndexRow (
241+ id = 1 ,
242+ type = SearchItemType .ENTITY .value ,
243+ title = "My Test Note" ,
244+ content_stems = "updated content here" , # Changed
245+ content_snippet = "This is the UPDATED content" , # Changed
246+ permalink = permalink , # Same permalink
247+ file_path = "test/my_note.md" ,
248+ entity_id = 1 ,
249+ metadata = {"entity_type" : "note" },
250+ created_at = datetime .now (timezone .utc ),
251+ updated_at = datetime .now (timezone .utc ),
252+ project_id = project_id ,
253+ )
254+
255+ # Re-index (should replace the old version)
256+ await repo .index_item (updated_row )
257+
258+ # Verify the old version is gone
259+ results_old = await repo .search (search_text = "initial content" )
260+ assert len (results_old ) == 0
261+
262+ # Verify the new version exists
263+ results_new = await repo .search (search_text = "updated content" )
264+ assert len (results_new ) == 1
265+ assert results_new [0 ].content_snippet == "This is the UPDATED content"
266+
267+ # Verify we only have one record (not duplicated)
268+ all_results = await repo .search (search_text = "My Test Note" )
269+ assert len (all_results ) == 1
270+
271+ await engine .dispose ()
0 commit comments