forensic-trails/tests/test_note_manager.py
2025-10-11 00:19:12 +02:00

525 lines
20 KiB
Python

"""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()