"""Unit tests for the note_manager module.""" import unittest import tempfile import os import sqlite3 from pathlib import Path from unittest.mock import patch from datetime import datetime import time # Add the src directory to the path import sys sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) from forensictrails.core.note_manager import NoteManager from forensictrails.core.case_manager import CaseManager from forensictrails.db.database import create_fresh_database class TestNoteManager(unittest.TestCase): """Test cases for NoteManager class.""" def setUp(self): """Set up test fixtures.""" self.temp_dir = tempfile.mkdtemp() self.test_db_path = os.path.join(self.temp_dir, 'test.db') self.schema_path = Path(__file__).parent.parent / 'src' / 'forensictrails' / 'db' / 'schema.sql' # Create a fresh database for testing create_fresh_database(self.test_db_path, self.schema_path) # Create manager instances self.case_manager = CaseManager(self.test_db_path) self.note_manager = NoteManager(self.test_db_path) # Create a test case for notes self.case_id = self.case_manager.create_case( description='Test Case for Notes', investigator_name='Detective Smith' ) def tearDown(self): """Clean up test fixtures.""" if hasattr(self, 'note_manager') and hasattr(self.note_manager, 'conn'): self.note_manager.conn.close() if hasattr(self, 'case_manager') and hasattr(self.case_manager, 'conn'): self.case_manager.conn.close() if os.path.exists(self.test_db_path): os.remove(self.test_db_path) os.rmdir(self.temp_dir) def test_note_manager_initialization(self): """Test NoteManager initializes correctly.""" self.assertEqual(self.note_manager.db_path, self.test_db_path) self.assertIsNotNone(self.note_manager.conn) self.assertIsNotNone(self.note_manager.cursor) def test_create_note_basic(self): """Test creating a basic note.""" note_id = self.note_manager.create_note( case_id=self.case_id, content='This is a test note.', investigator='Detective Smith' ) self.assertIsNotNone(note_id) self.assertIsInstance(note_id, int) # Verify note was created note = self.note_manager.get_note(note_id) self.assertIsNotNone(note) assert note is not None # Type narrowing for Pylance self.assertEqual(note['note_id'], note_id) self.assertEqual(note['case_id'], self.case_id) self.assertEqual(note['content'], 'This is a test note.') self.assertEqual(note['investigator'], 'Detective Smith') self.assertEqual(note['question_tags'], []) def test_create_note_with_tags(self): """Test creating a note with question tags.""" note_id = self.note_manager.create_note( case_id=self.case_id, content='Who was at the scene?', investigator='Detective Jones', question_tags=['WHO', 'WHEN'] ) note = self.note_manager.get_note(note_id) self.assertIsNotNone(note) assert note is not None # Type narrowing for Pylance self.assertIn('WHO', note['question_tags']) self.assertIn('WHEN', note['question_tags']) def test_get_note_nonexistent(self): """Test getting a non-existent note returns None.""" note = self.note_manager.get_note(99999) self.assertIsNone(note) def test_get_notes_empty(self): """Test getting notes for a case with no notes.""" notes = self.note_manager.get_notes(self.case_id) self.assertEqual(notes, []) def test_get_notes_multiple(self): """Test getting multiple notes for a case.""" # Create multiple notes note_id1 = self.note_manager.create_note( self.case_id, 'First note', 'Detective A' ) time.sleep(0.01) # Ensure different timestamps note_id2 = self.note_manager.create_note( self.case_id, 'Second note', 'Detective B' ) time.sleep(0.01) note_id3 = self.note_manager.create_note( self.case_id, 'Third note', 'Detective C' ) notes = self.note_manager.get_notes(self.case_id) self.assertEqual(len(notes), 3) note_ids = [n['note_id'] for n in notes] self.assertIn(note_id1, note_ids) self.assertIn(note_id2, note_ids) self.assertIn(note_id3, note_ids) # Verify they're ordered by creation time (DESC) # Check that timestamps are in descending order timestamps = [n['timestamp'] for n in notes] self.assertEqual(timestamps, sorted(timestamps, reverse=True)) def test_update_note_creates_revision(self): """Test that updating a note creates a new revision.""" note_id = self.note_manager.create_note( self.case_id, 'Original content', 'Detective Smith' ) # Update the note result = self.note_manager.update_note(note_id, 'Updated content') self.assertTrue(result) # Verify the note now shows updated content note = self.note_manager.get_note(note_id) assert note is not None # Type narrowing for Pylance self.assertEqual(note['content'], 'Updated content') # Verify history exists history = self.note_manager.get_note_history(note_id) self.assertEqual(len(history), 2) self.assertEqual(history[0]['content'], 'Updated content') # Newest first self.assertEqual(history[0]['revision_number'], 2) self.assertEqual(history[1]['content'], 'Original content') self.assertEqual(history[1]['revision_number'], 1) def test_update_note_multiple_times(self): """Test updating a note multiple times.""" note_id = self.note_manager.create_note( self.case_id, 'Version 1', 'Detective Smith' ) self.note_manager.update_note(note_id, 'Version 2') self.note_manager.update_note(note_id, 'Version 3') self.note_manager.update_note(note_id, 'Version 4') # Verify latest version note = self.note_manager.get_note(note_id) assert note is not None # Type narrowing for Pylance self.assertEqual(note['content'], 'Version 4') # Verify all revisions exist history = self.note_manager.get_note_history(note_id) self.assertEqual(len(history), 4) for i, rev in enumerate(history): self.assertEqual(rev['revision_number'], 4 - i) def test_get_note_history_empty(self): """Test getting history for non-existent note.""" history = self.note_manager.get_note_history(99999) self.assertEqual(history, []) def test_update_note_tags(self): """Test updating question tags for a note.""" note_id = self.note_manager.create_note( self.case_id, 'Test note', 'Detective Smith', question_tags=['WHO'] ) # Verify initial tags note = self.note_manager.get_note(note_id) assert note is not None # Type narrowing for Pylance self.assertEqual(note['question_tags'], ['WHO']) # Update tags result = self.note_manager.update_note_tags(note_id, ['WHAT', 'WHEN', 'WHERE']) self.assertTrue(result) # Verify updated tags note = self.note_manager.get_note(note_id) assert note is not None # Type narrowing for Pylance self.assertIn('WHAT', note['question_tags']) self.assertIn('WHEN', note['question_tags']) self.assertIn('WHERE', note['question_tags']) self.assertNotIn('WHO', note['question_tags']) def test_update_note_tags_remove_all(self): """Test removing all tags from a note.""" note_id = self.note_manager.create_note( self.case_id, 'Test note', 'Detective Smith', question_tags=['WHO', 'WHAT'] ) # Remove all tags result = self.note_manager.update_note_tags(note_id, []) self.assertTrue(result) # Verify no tags note = self.note_manager.get_note(note_id) assert note is not None # Type narrowing for Pylance self.assertEqual(note['question_tags'], []) def test_update_note_tags_nonexistent_note(self): """Test updating tags for non-existent note.""" result = self.note_manager.update_note_tags(99999, ['WHO']) self.assertFalse(result) def test_delete_note(self): """Test deleting a note.""" note_id = self.note_manager.create_note( self.case_id, 'Test note', 'Detective Smith' ) # Verify note exists note = self.note_manager.get_note(note_id) self.assertIsNotNone(note) # Delete note result = self.note_manager.delete_note(note_id) self.assertTrue(result) # Verify note is gone note = self.note_manager.get_note(note_id) self.assertIsNone(note) def test_delete_note_deletes_revisions(self): """Test that deleting a note also deletes all revisions.""" note_id = self.note_manager.create_note( self.case_id, 'Original', 'Detective Smith' ) self.note_manager.update_note(note_id, 'Updated') self.note_manager.update_note(note_id, 'Updated again') # Delete note self.note_manager.delete_note(note_id) # Verify revisions are gone history = self.note_manager.get_note_history(note_id) self.assertEqual(history, []) def test_delete_nonexistent_note(self): """Test deleting a non-existent note returns False.""" result = self.note_manager.delete_note(99999) self.assertFalse(result) def test_search_notes_by_case(self): """Test searching notes by case ID.""" # Create second case case_id2 = self.case_manager.create_case( description='Second Case', investigator_name='Detective Jones' ) # Create notes in both cases self.note_manager.create_note(self.case_id, 'Case 1 Note 1', 'Detective A') self.note_manager.create_note(self.case_id, 'Case 1 Note 2', 'Detective B') self.note_manager.create_note(case_id2, 'Case 2 Note 1', 'Detective C') # Search by case case1_notes = self.note_manager.search_notes(case_id=self.case_id) case2_notes = self.note_manager.search_notes(case_id=case_id2) self.assertEqual(len(case1_notes), 2) self.assertEqual(len(case2_notes), 1) def test_search_notes_by_content(self): """Test searching notes by content text.""" self.note_manager.create_note(self.case_id, 'This is about murder', 'Detective A') self.note_manager.create_note(self.case_id, 'This is about theft', 'Detective B') self.note_manager.create_note(self.case_id, 'Another murder note', 'Detective C') # Search for "murder" results = self.note_manager.search_notes(case_id=self.case_id, search_term='murder') self.assertEqual(len(results), 2) # Search for "theft" results = self.note_manager.search_notes(case_id=self.case_id, search_term='theft') self.assertEqual(len(results), 1) def test_search_notes_by_question_tags(self): """Test searching notes by question tags.""" self.note_manager.create_note( self.case_id, 'Note 1', 'Detective A', question_tags=['WHO', 'WHAT'] ) self.note_manager.create_note( self.case_id, 'Note 2', 'Detective B', question_tags=['WHEN', 'WHERE'] ) self.note_manager.create_note( self.case_id, 'Note 3', 'Detective C', question_tags=['WHO', 'WHEN'] ) # Search for WHO tags results = self.note_manager.search_notes(case_id=self.case_id, question_tags=['WHO']) self.assertEqual(len(results), 2) # Search for WHERE tags results = self.note_manager.search_notes(case_id=self.case_id, question_tags=['WHERE']) self.assertEqual(len(results), 1) def test_search_notes_combined_filters(self): """Test searching notes with multiple filters.""" self.note_manager.create_note( self.case_id, 'Murder investigation note', 'Detective A', question_tags=['WHO'] ) self.note_manager.create_note( self.case_id, 'Theft investigation note', 'Detective B', question_tags=['WHO'] ) self.note_manager.create_note( self.case_id, 'Murder witness statement', 'Detective C', question_tags=['WHAT'] ) # Search for "murder" with WHO tag results = self.note_manager.search_notes( case_id=self.case_id, search_term='murder', question_tags=['WHO'] ) self.assertEqual(len(results), 1) self.assertIn('Murder investigation', results[0]['content']) def test_search_notes_all_cases(self): """Test searching notes across all cases.""" case_id2 = self.case_manager.create_case( description='Second Case', investigator_name='Detective Jones' ) self.note_manager.create_note(self.case_id, 'Case 1 evidence', 'Detective A') self.note_manager.create_note(case_id2, 'Case 2 evidence', 'Detective B') # Search without case_id filter results = self.note_manager.search_notes(search_term='evidence') self.assertEqual(len(results), 2) def test_investigator_reuse(self): """Test that investigators are reused across notes.""" note_id1 = self.note_manager.create_note( self.case_id, 'Note 1', 'Detective Smith' ) note_id2 = self.note_manager.create_note( self.case_id, 'Note 2', 'Detective Smith' ) # Both notes should reference the same investigator note1 = self.note_manager.get_note(note_id1) note2 = self.note_manager.get_note(note_id2) assert note1 is not None and note2 is not None # Type narrowing for Pylance self.assertEqual(note1['investigator'], note2['investigator']) def test_question_definition_reuse(self): """Test that question definitions are reused within a case.""" note_id1 = self.note_manager.create_note( self.case_id, 'Note 1', 'Detective A', question_tags=['WHO'] ) note_id2 = self.note_manager.create_note( self.case_id, 'Note 2', 'Detective B', question_tags=['WHO'] ) # Both notes should use the same question definition # Verify by checking the database directly cursor = self.note_manager.cursor cursor.execute(""" SELECT COUNT(DISTINCT question_id) as count FROM question_definition WHERE case_id = ? AND question_text = 'WHO' """, (self.case_id,)) result = cursor.fetchone() self.assertEqual(result['count'], 1) def test_note_manager_with_default_db_path(self): """Test NoteManager uses config default when no path provided.""" with patch('forensictrails.core.note_manager.config') as mock_config: mock_config.database_path = self.test_db_path nm = NoteManager() self.assertEqual(nm.db_path, self.test_db_path) nm.conn.close() class TestNoteManagerIntegration(unittest.TestCase): """Integration tests for NoteManager.""" def setUp(self): """Set up test fixtures.""" self.temp_dir = tempfile.mkdtemp() self.test_db_path = os.path.join(self.temp_dir, 'integration.db') self.schema_path = Path(__file__).parent.parent / 'src' / 'forensictrails' / 'db' / 'schema.sql' create_fresh_database(self.test_db_path, self.schema_path) self.case_manager = CaseManager(self.test_db_path) self.note_manager = NoteManager(self.test_db_path) self.case_id = self.case_manager.create_case( description='Integration Test Case', investigator_name='Detective Smith' ) def tearDown(self): """Clean up test fixtures.""" if hasattr(self, 'note_manager') and hasattr(self.note_manager, 'conn'): self.note_manager.conn.close() if hasattr(self, 'case_manager') and hasattr(self.case_manager, 'conn'): self.case_manager.conn.close() if os.path.exists(self.test_db_path): os.remove(self.test_db_path) os.rmdir(self.temp_dir) def test_full_note_lifecycle(self): """Test complete note lifecycle: create, update, tag, delete.""" # Create note note_id = self.note_manager.create_note( self.case_id, 'Initial observation', 'Detective Smith', question_tags=['WHAT'] ) # Verify creation note = self.note_manager.get_note(note_id) assert note is not None # Type narrowing for Pylance self.assertEqual(note['content'], 'Initial observation') self.assertEqual(note['question_tags'], ['WHAT']) # Update content self.note_manager.update_note(note_id, 'Updated observation with more details') note = self.note_manager.get_note(note_id) assert note is not None # Type narrowing for Pylance self.assertEqual(note['content'], 'Updated observation with more details') # Update tags self.note_manager.update_note_tags(note_id, ['WHAT', 'WHERE', 'WHEN']) note = self.note_manager.get_note(note_id) assert note is not None # Type narrowing for Pylance self.assertEqual(len(note['question_tags']), 3) # Verify history history = self.note_manager.get_note_history(note_id) self.assertEqual(len(history), 2) # Delete note self.note_manager.delete_note(note_id) note = self.note_manager.get_note(note_id) self.assertIsNone(note) def test_case_with_multiple_notes_and_tags(self): """Test a case with multiple notes and complex tagging.""" # Create various notes with different tags notes_data = [ ('Suspect identified as John Doe', ['WHO']), ('Crime occurred at 123 Main St', ['WHERE']), ('Incident happened on Jan 15, 2025', ['WHEN']), ('Weapon used was a knife', ['WITH_WHAT', 'WHAT']), ('Motive appears to be robbery', ['WHY', 'WHAT']), ] note_ids = [] for content, tags in notes_data: note_id = self.note_manager.create_note( self.case_id, content, 'Detective Smith', question_tags=tags ) note_ids.append(note_id) # Verify all notes created all_notes = self.note_manager.get_notes(self.case_id) self.assertEqual(len(all_notes), 5) # Search by specific tags who_notes = self.note_manager.search_notes(case_id=self.case_id, question_tags=['WHO']) self.assertEqual(len(who_notes), 1) what_notes = self.note_manager.search_notes(case_id=self.case_id, question_tags=['WHAT']) # Should find notes with WHAT tag: 'Weapon used was a knife' and 'Motive appears to be robbery' self.assertEqual(len(what_notes), 2) def test_note_revision_immutability(self): """Test that note revisions are truly immutable.""" note_id = self.note_manager.create_note( self.case_id, 'Version 1', 'Detective Smith' ) # Create multiple revisions for i in range(2, 6): self.note_manager.update_note(note_id, f'Version {i}') # Get full history history = self.note_manager.get_note_history(note_id) # Verify all revisions are preserved self.assertEqual(len(history), 5) # Verify each revision has correct content for i, revision in enumerate(reversed(history)): self.assertEqual(revision['revision_number'], i + 1) self.assertEqual(revision['content'], f'Version {i + 1}') # Verify timestamp exists self.assertIsNotNone(revision['timestamp']) if __name__ == '__main__': unittest.main()