"""Tests for infrastructure.error_capture module.""" import sqlite3 from datetime import UTC, datetime, timedelta from unittest.mock import patch from infrastructure.error_capture import ( _build_report_description, _create_bug_report, _dedup_cache, _extract_traceback_info, _get_git_context, _is_duplicate, _log_bug_report_created, _log_error_event, _notify_bug_report, _record_to_session, _stack_hash, capture_error, ) def _make_exception(): """Helper that always raises from the same line for stable hashing.""" raise ValueError("test error") class TestStackHash: """Test _stack_hash produces stable hashes.""" def test_hash_is_deterministic_for_same_exception(self): """Same exception object always produces the same hash.""" try: _make_exception() except ValueError as e: hash1 = _stack_hash(e) hash2 = _stack_hash(e) assert hash1 == hash2 def test_different_exception_types_differ(self): try: raise ValueError("x") except ValueError as e1: hash1 = _stack_hash(e1) try: raise TypeError("x") except TypeError as e2: hash2 = _stack_hash(e2) assert hash1 != hash2 def test_hash_is_hex_string(self): try: raise RuntimeError("test") except RuntimeError as e: h = _stack_hash(e) assert len(h) == 16 assert all(c in "0123456789abcdef" for c in h) class TestIsDuplicate: """Test deduplication logic.""" def setup_method(self): _dedup_cache.clear() def test_first_occurrence_not_duplicate(self): assert _is_duplicate("hash_abc") is False def test_second_occurrence_is_duplicate(self): _is_duplicate("hash_dup") assert _is_duplicate("hash_dup") is True def test_different_hashes_not_duplicates(self): _is_duplicate("hash_1") assert _is_duplicate("hash_2") is False def test_stale_entries_pruned(self): """Old entries beyond 2x the dedup window should be pruned.""" from config import settings window = settings.error_dedup_window_seconds # Seed a stale entry far in the past stale_time = datetime.now(UTC) - timedelta(seconds=window * 3) _dedup_cache["stale_hash"] = stale_time # Calling _is_duplicate on a new hash triggers pruning _is_duplicate("fresh_hash") assert "stale_hash" not in _dedup_cache assert "fresh_hash" in _dedup_cache def teardown_method(self): _dedup_cache.clear() class TestGetGitContext: """Test _get_git_context.""" def test_returns_dict_with_branch_and_commit(self): """Git context always returns a dict with branch and commit keys.""" ctx = _get_git_context() assert "branch" in ctx assert "commit" in ctx assert isinstance(ctx["branch"], str) assert isinstance(ctx["commit"], str) def test_git_context_fallback_on_failure(self): """When subprocess.run fails, returns 'unknown' for both fields.""" with patch("subprocess.run", side_effect=OSError("git not found")): ctx = _get_git_context() assert ctx == {"branch": "unknown", "commit": "unknown"} class TestCaptureError: """Test the main capture_error function.""" def setup_method(self): _dedup_cache.clear() def test_duplicate_returns_none(self): """Second call with same exception is deduplicated.""" try: _make_exception() except ValueError as e: # First call capture_error(e, source="test") # Second call — same hash, within dedup window result = capture_error(e, source="test") assert result is None def test_returns_none_when_feedback_disabled(self): """capture_error returns None immediately when error_feedback_enabled is False.""" _dedup_cache.clear() import config original = config.settings.error_feedback_enabled try: config.settings.error_feedback_enabled = False try: raise RuntimeError("disabled test") except RuntimeError as e: result = capture_error(e, source="test") assert result is None finally: config.settings.error_feedback_enabled = original def test_capture_does_not_crash_on_missing_deps(self): """capture_error should never crash even if optional deps are missing.""" _dedup_cache.clear() try: raise OSError("graceful test") except OSError as e: # Should not raise even though swarm/event_log don't exist capture_error(e, source="graceful") def test_capture_with_context_does_not_crash(self): """capture_error with context dict should not crash.""" _dedup_cache.clear() try: raise RuntimeError("context test") except RuntimeError as e: capture_error(e, source="test_module", context={"path": "/api/foo"}) def test_capture_creates_task_in_temp_db(self): """capture_error should write a task to the isolated temp DB, not production. This validates that conftest.clean_database properly redirects swarm.task_queue.models.DB_PATH to the per-test temp directory. """ _dedup_cache.clear() try: raise ValueError("Test error for DB isolation check") except ValueError as e: task_id = capture_error(e, source="test") # task_id can be None if error_feedback_enabled is False — that's fine, # but if it was created, verify it landed in the temp DB if task_id: import swarm.task_queue.models as tq_models db_path = tq_models.DB_PATH conn = sqlite3.connect(str(db_path)) try: row = conn.execute( "SELECT id, title FROM tasks WHERE id = ?", (task_id,) ).fetchone() assert row is not None, ( f"Task {task_id} not found in DB at {db_path} — " "check conftest.clean_database patches swarm.task_queue.models.DB_PATH" ) assert "[BUG]" in row[1] finally: conn.close() def teardown_method(self): _dedup_cache.clear() class TestExtractTracebackInfo: """Test _extract_traceback_info helper.""" def test_returns_three_tuple(self): try: raise ValueError("extract test") except ValueError as e: tb_str, affected_file, affected_line = _extract_traceback_info(e) assert "ValueError" in tb_str assert "extract test" in tb_str assert affected_file.endswith(".py") assert affected_line > 0 def test_file_points_to_raise_site(self): try: _make_exception() except ValueError as e: _, affected_file, _ = _extract_traceback_info(e) assert "test_error_capture" in affected_file class TestLogErrorEvent: """Test _log_error_event helper.""" def test_does_not_crash_on_missing_deps(self): try: raise RuntimeError("log test") except RuntimeError as e: _log_error_event(e, "test", "abc123", "file.py", 42, {"branch": "main"}) class TestBuildReportDescription: """Test _build_report_description helper.""" def test_includes_error_info(self): try: raise RuntimeError("desc test") except RuntimeError as e: desc = _build_report_description( e, "test_src", None, "hash1", "tb...", "file.py", 10, {"branch": "main"}, ) assert "RuntimeError" in desc assert "test_src" in desc assert "file.py:10" in desc assert "hash1" in desc def test_includes_context_when_provided(self): try: raise RuntimeError("ctx desc") except RuntimeError as e: desc = _build_report_description( e, "src", {"path": "/api"}, "h", "tb", "f.py", 1, {}, ) assert "path=/api" in desc def test_omits_context_when_none(self): try: raise RuntimeError("no ctx") except RuntimeError as e: desc = _build_report_description( e, "src", None, "h", "tb", "f.py", 1, {}, ) assert "**Context:**" not in desc class TestLogBugReportCreated: """Test _log_bug_report_created helper.""" def test_does_not_crash_on_missing_deps(self): _log_bug_report_created("test", "task-1", "hash1", "title") class TestCreateBugReport: """Test _create_bug_report helper.""" def test_does_not_crash_on_missing_deps(self): try: raise RuntimeError("report test") except RuntimeError as e: result = _create_bug_report( e, "test", None, "abc123", "traceback...", "file.py", 42, {} ) # May return None if swarm deps unavailable — that's fine assert result is None or isinstance(result, str) def test_with_context(self): try: raise RuntimeError("ctx test") except RuntimeError as e: result = _create_bug_report(e, "test", {"path": "/api"}, "abc", "tb", "f.py", 1, {}) assert result is None or isinstance(result, str) class TestNotifyBugReport: """Test _notify_bug_report helper.""" def test_does_not_crash(self): try: raise RuntimeError("notify test") except RuntimeError as e: _notify_bug_report(e, "test") class TestRecordToSession: """Test _record_to_session helper.""" def test_does_not_crash_without_recorder(self): try: raise RuntimeError("session test") except RuntimeError as e: _record_to_session(e, "test") def test_calls_registered_recorder(self): from infrastructure.error_capture import register_error_recorder calls = [] register_error_recorder(lambda **kwargs: calls.append(kwargs)) try: try: raise RuntimeError("callback test") except RuntimeError as e: _record_to_session(e, "test_source") assert len(calls) == 1 assert "RuntimeError" in calls[0]["error"] assert calls[0]["context"] == "test_source" finally: register_error_recorder(None)