""" Tests for web/models/database.py Tests cover: - get_db_connection (deprecated) - calculate_duration function - get_logs_for_research function - get_total_logs_for_research function """ import pytest from unittest.mock import Mock, patch from datetime import datetime, timezone, timedelta class TestGetDbConnection: """Tests for deprecated get_db_connection function.""" def test_get_db_connection_raises_runtime_error(self): """Test that get_db_connection raises RuntimeError.""" from local_deep_research.web.models.database import get_db_connection with pytest.raises(RuntimeError) as exc_info: get_db_connection() assert "Shared database access is deprecated" in str(exc_info.value) class TestCalculateDuration: """Tests for calculate_duration function.""" def test_calculate_duration_iso_format(self): """Test duration calculation with ISO format timestamps.""" from local_deep_research.web.models.database import calculate_duration created_at = "2025-01-01T10:00:00+00:00" completed_at = "2025-01-01T10:05:00+00:00" result = calculate_duration(created_at, completed_at) assert result == 300 # 5 minutes = 300 seconds def test_calculate_duration_with_microseconds(self): """Test duration calculation with microseconds in ISO format.""" from local_deep_research.web.models.database import calculate_duration # Use ISO format with timezone for proper parsing created_at = "2025-01-01T10:00:00.123456+00:00" completed_at = "2025-01-01T10:01:00.654321+00:00" result = calculate_duration(created_at, completed_at) # Should be approximately 60 seconds assert result == 60 or result == 61 def test_calculate_duration_without_completed_at(self): """Test duration calculation without completed_at uses current time.""" from local_deep_research.web.models.database import calculate_duration # Create a recent timestamp now = datetime.now(timezone.utc) created_at = (now - timedelta(seconds=120)).isoformat() result = calculate_duration(created_at) # Should be approximately 120 seconds (with some tolerance) assert result is not None assert 115 <= result <= 130 def test_calculate_duration_none_created_at(self): """Test duration calculation with None created_at.""" from local_deep_research.web.models.database import calculate_duration result = calculate_duration(None) assert result is None def test_calculate_duration_empty_created_at(self): """Test duration calculation with empty created_at.""" from local_deep_research.web.models.database import calculate_duration result = calculate_duration("") assert result is None def test_calculate_duration_space_separated_format(self): """Test duration with space-separated timestamp format with timezone.""" from local_deep_research.web.models.database import calculate_duration # Use format with timezone suffix for proper handling created_at = "2025-01-01 10:00:00+00:00" completed_at = "2025-01-01 10:10:00+00:00" result = calculate_duration(created_at, completed_at) assert result == 600 # 10 minutes = 600 seconds def test_calculate_duration_replaces_space_with_t(self): """Test that space is replaced with T for parsing.""" from local_deep_research.web.models.database import calculate_duration # This format should fall back to replacing space with T created_at = "2025-01-01 10:00:00+00:00" completed_at = "2025-01-01 10:02:00+00:00" result = calculate_duration(created_at, completed_at) assert result == 120 # 2 minutes class TestGetLogsForResearch: """Tests for get_logs_for_research function.""" def test_get_logs_success(self): """Test successful log retrieval.""" mock_session = Mock() mock_log = Mock() mock_log.timestamp = "2025-01-01T10:00:00" mock_log.message = "Test log message" mock_log.level = "INFO" mock_log.module = "test_module" mock_log.line_no = 42 mock_query = Mock() mock_query.filter.return_value = mock_query mock_query.order_by.return_value = mock_query mock_query.all.return_value = [mock_log] mock_session.query.return_value = mock_query with patch( "local_deep_research.web.models.database.get_user_db_session" ) as mock_get_session: mock_get_session.return_value.__enter__ = Mock( return_value=mock_session ) mock_get_session.return_value.__exit__ = Mock(return_value=False) from local_deep_research.web.models.database import ( get_logs_for_research, ) result = get_logs_for_research("test-research-id") assert len(result) == 1 assert result[0]["message"] == "Test log message" assert result[0]["type"] == "INFO" assert result[0]["module"] == "test_module" assert result[0]["line_no"] == 42 def test_get_logs_empty(self): """Test log retrieval when no logs exist.""" mock_session = Mock() mock_query = Mock() mock_query.filter.return_value = mock_query mock_query.order_by.return_value = mock_query mock_query.all.return_value = [] mock_session.query.return_value = mock_query with patch( "local_deep_research.web.models.database.get_user_db_session" ) as mock_get_session: mock_get_session.return_value.__enter__ = Mock( return_value=mock_session ) mock_get_session.return_value.__exit__ = Mock(return_value=False) from local_deep_research.web.models.database import ( get_logs_for_research, ) result = get_logs_for_research("nonexistent-id") assert result == [] def test_get_logs_error_returns_empty(self): """Test that errors return empty list.""" with patch( "local_deep_research.web.models.database.get_user_db_session" ) as mock_get_session: mock_get_session.return_value.__enter__ = Mock( side_effect=Exception("Database error") ) from local_deep_research.web.models.database import ( get_logs_for_research, ) result = get_logs_for_research("test-id") assert result == [] def test_get_logs_with_limit_uses_desc_order_and_reverses(self): """When limit is set, the query takes the newest N rows (desc) and the result is reversed so the caller still sees oldest-first. """ # Three rows ordered newest-first as the DB would return them. rows = [] for i, msg in enumerate(["newest", "middle", "oldest"]): row = Mock() row.timestamp = f"2025-01-01T10:0{2 - i}:00" row.message = msg row.level = "INFO" row.module = "m" row.line_no = i rows.append(row) mock_session = Mock() mock_query = Mock() mock_query.filter.return_value = mock_query mock_query.order_by.return_value = mock_query mock_query.limit.return_value = mock_query mock_query.all.return_value = rows mock_session.query.return_value = mock_query with patch( "local_deep_research.web.models.database.get_user_db_session" ) as mock_get_session: mock_get_session.return_value.__enter__ = Mock( return_value=mock_session ) mock_get_session.return_value.__exit__ = Mock(return_value=False) from local_deep_research.web.models.database import ( get_logs_for_research, ) result = get_logs_for_research("rid", limit=3) # limit() was called with the requested cap. mock_query.limit.assert_called_once_with(3) # Caller sees oldest-first ordering. assert [r["message"] for r in result] == [ "oldest", "middle", "newest", ] def test_get_logs_with_limit_returns_newest_n_in_ascending_order_real_sqlite( self, ): """End-to-end SQL check against a real in-memory SQLite session. The mock-based ordering test above only proves Python's ``reversed()`` is called — the mock returns a fixed list regardless of which ``order_by``/``limit`` was invoked. This test inserts 10 actual rows, runs the function through SQLAlchemy, and asserts that the SQL produces newest-first DESC + LIMIT and the caller sees the 3 newest rows in oldest-first order. Catches any regression where ``desc()`` is dropped, ``limit()`` is moved, or ``reversed()`` is removed. """ from datetime import datetime, timedelta, timezone from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from local_deep_research.database.models import Base, ResearchLog from local_deep_research.web.models.database import ( get_logs_for_research, ) engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() # 10 orphan log rows at 1-minute intervals. No parent ResearchHistory # row is created — research_id is nullable and SQLite does not enforce # FK constraints without PRAGMA foreign_keys=ON, so the insert is # valid and isolates this test from the parent-table schema. base_time = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc) for i in range(10): session.add( ResearchLog( research_id="test-rid", timestamp=base_time + timedelta(minutes=i), message=f"Log {i}", module="test", function="test", line_no=i, level="INFO", ) ) session.commit() try: with patch( "local_deep_research.web.models.database.get_user_db_session" ) as mock_get_session: mock_get_session.return_value.__enter__ = Mock( return_value=session ) mock_get_session.return_value.__exit__ = Mock( return_value=False ) result = get_logs_for_research("test-rid", limit=3) assert [r["message"] for r in result] == [ "Log 7", "Log 8", "Log 9", ] finally: session.close() engine.dispose() def test_get_logs_with_limit_tie_breaks_equal_timestamps_by_id(self): """When rows share an identical timestamp, ``id`` is the tie-break so the newest ``limit`` selection is deterministic. ``timestamp`` is not unique, so ``order_by(timestamp.desc()).limit(N)`` alone leaves the rows surviving the boundary SQL-undefined. All 10 rows here share one timestamp; Log i has id i+1, so the newest 3 by id are Log 7/8/9, returned oldest-first. """ from datetime import datetime, timezone from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from local_deep_research.database.models import Base, ResearchLog from local_deep_research.web.models.database import ( get_logs_for_research, ) engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) session = sessionmaker(bind=engine)() shared_time = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc) for i in range(10): session.add( ResearchLog( research_id="test-rid", timestamp=shared_time, message=f"Log {i}", module="test", function="test", line_no=i, level="INFO", ) ) session.commit() try: with patch( "local_deep_research.web.models.database.get_user_db_session" ) as mock_get_session: mock_get_session.return_value.__enter__ = Mock( return_value=session ) mock_get_session.return_value.__exit__ = Mock( return_value=False ) result = get_logs_for_research("test-rid", limit=3) assert [r["message"] for r in result] == [ "Log 7", "Log 8", "Log 9", ] finally: session.close() engine.dispose() def test_get_logs_without_limit_does_not_call_limit(self): """When limit is None, the function preserves legacy behaviour and does not invoke .limit() on the query (returns every row, asc).""" mock_session = Mock() mock_query = Mock() mock_query.filter.return_value = mock_query mock_query.order_by.return_value = mock_query mock_query.all.return_value = [] # Track whether limit() is invoked — it should NOT be. mock_query.limit = Mock(side_effect=AssertionError("limit() called")) mock_session.query.return_value = mock_query with patch( "local_deep_research.web.models.database.get_user_db_session" ) as mock_get_session: mock_get_session.return_value.__enter__ = Mock( return_value=mock_session ) mock_get_session.return_value.__exit__ = Mock(return_value=False) from local_deep_research.web.models.database import ( get_logs_for_research, ) # Should complete without triggering the AssertionError side-effect. result = get_logs_for_research("rid") assert result == [] class TestGetTotalLogsForResearch: """Tests for get_total_logs_for_research function.""" def test_get_total_logs_success(self): """Test successful total log count.""" mock_session = Mock() mock_query = Mock() mock_query.filter.return_value = mock_query mock_query.count.return_value = 42 mock_session.query.return_value = mock_query with patch( "local_deep_research.web.models.database.get_user_db_session" ) as mock_get_session: mock_get_session.return_value.__enter__ = Mock( return_value=mock_session ) mock_get_session.return_value.__exit__ = Mock(return_value=False) from local_deep_research.web.models.database import ( get_total_logs_for_research, ) result = get_total_logs_for_research("test-research-id") assert result == 42 def test_get_total_logs_zero(self): """Test total log count when no logs exist.""" mock_session = Mock() mock_query = Mock() mock_query.filter.return_value = mock_query mock_query.count.return_value = 0 mock_session.query.return_value = mock_query with patch( "local_deep_research.web.models.database.get_user_db_session" ) as mock_get_session: mock_get_session.return_value.__enter__ = Mock( return_value=mock_session ) mock_get_session.return_value.__exit__ = Mock(return_value=False) from local_deep_research.web.models.database import ( get_total_logs_for_research, ) result = get_total_logs_for_research("nonexistent-id") assert result == 0 class TestDurationEdgeCases: """Edge case tests for calculate_duration.""" def test_negative_duration(self): """Test when completed_at is before created_at.""" from local_deep_research.web.models.database import calculate_duration created_at = "2025-01-01T10:10:00+00:00" completed_at = "2025-01-01T10:00:00+00:00" result = calculate_duration(created_at, completed_at) # Should return negative value assert result == -600 # -10 minutes def test_very_long_duration(self): """Test with very long duration.""" from local_deep_research.web.models.database import calculate_duration created_at = "2025-01-01T00:00:00+00:00" completed_at = "2025-01-02T00:00:00+00:00" result = calculate_duration(created_at, completed_at) assert result == 86400 # 24 hours = 86400 seconds def test_zero_duration(self): """Test with same timestamps.""" from local_deep_research.web.models.database import calculate_duration created_at = "2025-01-01T10:00:00+00:00" completed_at = "2025-01-01T10:00:00+00:00" result = calculate_duration(created_at, completed_at) assert result == 0