"""Tests for structured error classification (error code system). Covers: classify_error() mapping exceptions to E_* codes, _emit_error() outputting error_code in JSON mode, _run_single_query() propagating error_code from caught exceptions. """ import json import urllib.error from types import SimpleNamespace from unittest.mock import patch import pytest from common import ( classify_error, E_CONFIG, E_AUTH, E_NETWORK, E_RATE_LIMIT, E_PARSE, E_EMPTY, E_INPUT, E_INTERNAL, ) from search import _emit_error, _run_single_query # ----- classify_error: HTTP errors ----- def test_classify_429_is_rate_limit(): err = urllib.error.HTTPError("url", 429, "Too Many Requests", {}, None) assert classify_error(err) == E_RATE_LIMIT def test_classify_401_is_auth(): err = urllib.error.HTTPError("url", 401, "Unauthorized", {}, None) assert classify_error(err) == E_AUTH def test_classify_403_is_auth(): err = urllib.error.HTTPError("url", 403, "Forbidden", {}, None) assert classify_error(err) == E_AUTH def test_classify_404_is_input(): err = urllib.error.HTTPError("url", 404, "Not Found", {}, None) assert classify_error(err) == E_INPUT def test_classify_400_is_input(): err = urllib.error.HTTPError("url", 400, "Bad Request", {}, None) assert classify_error(err) == E_INPUT def test_classify_500_is_network(): err = urllib.error.HTTPError("url", 500, "Internal Server Error", {}, None) assert classify_error(err) == E_NETWORK def test_classify_503_is_network(): err = urllib.error.HTTPError("url", 503, "Service Unavailable", {}, None) assert classify_error(err) == E_NETWORK # ----- classify_error: connection errors ----- def test_classify_urlerror_is_network(): err = urllib.error.URLError("connection refused") assert classify_error(err) == E_NETWORK def test_classify_timeout_is_network(): assert classify_error(TimeoutError("timed out")) == E_NETWORK def test_classify_oserror_is_network(): assert classify_error(OSError("network unreachable")) == E_NETWORK # ----- classify_error: parse errors ----- def test_classify_value_error_is_parse(): assert classify_error(ValueError("invalid JSON")) == E_PARSE def test_classify_json_decode_error_is_parse(): import json as _json try: _json.loads("{bad}") assert False except _json.JSONDecodeError as e: assert classify_error(e) == E_PARSE # ----- classify_error: file/input errors ----- def test_classify_filenotfound_is_input(): assert classify_error(FileNotFoundError("no such file")) == E_INPUT # ----- classify_error: RuntimeError message inference ----- def test_classify_runtime_all_instances_failed_is_network(): err = RuntimeError("All 3 instances failed. Last error: timeout") assert classify_error(err) == E_NETWORK def test_classify_runtime_auth_message_is_auth(): err = RuntimeError("Auth failed: 403 Forbidden") assert classify_error(err) == E_AUTH def test_classify_runtime_rate_limit_message(): err = RuntimeError("Rate limited: 429") assert classify_error(err) == E_RATE_LIMIT def test_classify_runtime_parse_message_is_parse(): err = RuntimeError("Failed to parse JSON response") assert classify_error(err) == E_PARSE def test_classify_runtime_no_instance_is_config(): err = RuntimeError("No instance found in config") assert classify_error(err) == E_CONFIG def test_classify_runtime_unknown_is_internal(): err = RuntimeError("Something unexpected happened") assert classify_error(err) == E_INTERNAL # ----- classify_error: fallback ----- def test_classify_unknown_exception_is_internal(): assert classify_error(Exception("unknown")) == E_INTERNAL # ----- _emit_error: error_code in JSON output ----- def test_emit_error_json_includes_error_code(): """JSON mode: error_code appears in the JSON output.""" args = SimpleNamespace(format="json") with pytest.raises(SystemExit) as exc_info: _emit_error("connection refused", args, query="test", error_code=E_NETWORK) assert exc_info.value.code == 1 # Output already printed to stdout; we can't easily capture it here # without capsys, so we test via capsys below. def test_emit_error_json_with_code_and_query(capsys): """JSON mode: both error_code and query are in the output.""" args = SimpleNamespace(format="json") with pytest.raises(SystemExit): _emit_error("rate limited", args, query="news", error_code=E_RATE_LIMIT) out, _ = capsys.readouterr() data = json.loads(out) assert data["error"] == "rate limited" assert data["error_code"] == E_RATE_LIMIT assert data["query"] == "news" assert data["exit_code"] == 1 def test_emit_error_json_without_error_code(capsys): """JSON mode: error_code field omitted when not provided (backwards compat).""" args = SimpleNamespace(format="json") with pytest.raises(SystemExit): _emit_error("some error", args) out, _ = capsys.readouterr() data = json.loads(out) assert "error_code" not in data def test_emit_error_brief_includes_code_prefix(caplog): """Non-JSON mode: error_code appears as [E_*] prefix in log output.""" args = SimpleNamespace(format="brief") with pytest.raises(SystemExit): with caplog.at_level("ERROR"): _emit_error("not found", args, query="test", error_code=E_INPUT) assert "[E_INPUT]" in caplog.text # ----- _run_single_query: error_code propagation ----- def test_run_single_query_propagates_error_code(): """When search_multi raises, _run_single_query returns the classified code.""" import search as search_mod err = urllib.error.URLError("connection refused") args = SimpleNamespace( query="test", format="json", method="GET", timeout=15, retry=0, serial=False, no_dedup=False, sort_by="none", max_results=None, include_domain=None, exclude_domain=None, fetch=0, fetch_timeout=10, fetch_retries=3, max_size=None, cache_ttl=0, snippet_len=0, categories=None, language=None, pageno=1, time_range="year", safesearch=0, engines="google,bing", ) with patch.object(search_mod, "search_multi", side_effect=err): results, err_str, err_code = _run_single_query( "test", args, ["https://x.example.com"], {}, 0) assert results is None assert "connection refused" in err_str assert err_code == E_NETWORK def test_run_single_query_propagates_auth_error(): """HTTP 403 → error_code = E_AUTH.""" import search as search_mod err = urllib.error.HTTPError("url", 403, "Forbidden", {}, None) args = SimpleNamespace( query="test", format="json", method="GET", timeout=15, retry=0, serial=False, no_dedup=False, sort_by="none", max_results=None, include_domain=None, exclude_domain=None, fetch=0, fetch_timeout=10, fetch_retries=3, max_size=None, cache_ttl=0, snippet_len=0, categories=None, language=None, pageno=1, time_range="year", safesearch=0, engines="google,bing", ) with patch.object(search_mod, "search_multi", side_effect=err): _, _, err_code = _run_single_query( "test", args, ["https://x.example.com"], {}, 0) assert err_code == E_AUTH def test_run_single_query_propagates_rate_limit(): """HTTP 429 → error_code = E_RATE_LIMIT.""" import search as search_mod err = urllib.error.HTTPError("url", 429, "Too Many Requests", {}, None) args = SimpleNamespace( query="test", format="json", method="GET", timeout=15, retry=0, serial=False, no_dedup=False, sort_by="none", max_results=None, include_domain=None, exclude_domain=None, fetch=0, fetch_timeout=10, fetch_retries=3, max_size=None, cache_ttl=0, snippet_len=0, categories=None, language=None, pageno=1, time_range="year", safesearch=0, engines="google,bing", ) with patch.object(search_mod, "search_multi", side_effect=err): _, _, err_code = _run_single_query( "test", args, ["https://x.example.com"], {}, 0) assert err_code == E_RATE_LIMIT # ----- classify_error: HTTP status extraction from RuntimeError messages ----- # # search_multi wraps the last error into its RuntimeError message, e.g. # "All 3 instances failed. Last error: HTTP Error 403: Forbidden" # classify_error must extract the status code from that message rather than # always falling back to E_NETWORK. def test_classify_runtime_all_instances_failed_with_http_403_is_auth(): """'...Last error: HTTP Error 403' -> E_AUTH (not E_NETWORK).""" err = RuntimeError("All 3 instances failed. Last error: HTTP Error 403: Forbidden") assert classify_error(err) == E_AUTH def test_classify_runtime_all_instances_failed_with_http_500_is_network(): """'...Last error: HTTP Error 500' -> E_NETWORK (5xx server error).""" err = RuntimeError("All 3 instances failed. Last error: HTTP Error 500: Internal Server Error") assert classify_error(err) == E_NETWORK def test_classify_runtime_parallel_failed_with_http_429_is_rate_limit(): """'...(parallel). Last error: HTTP Error 429' -> E_RATE_LIMIT.""" err = RuntimeError("All 3 instances failed (parallel). Last error: HTTP Error 429: Too Many Requests") assert classify_error(err) == E_RATE_LIMIT # ----- _emit_error: recovery_hint in JSON output ----- # # recovery_hint gives AI agents an actionable suggestion per error_code. # Only present when error_code is known; omitted otherwise (backwards compat). def test_emit_error_json_includes_recovery_hint_for_config(capsys): """JSON error with E_CONFIG includes the config recovery_hint.""" args = SimpleNamespace(format="json") with pytest.raises(SystemExit): _emit_error("no instance resolved", args, error_code=E_CONFIG) out, _ = capsys.readouterr() data = json.loads(out) assert data["error_code"] == E_CONFIG assert "recovery_hint" in data hint = data["recovery_hint"].lower() assert "instance" in hint or "config" in hint def test_emit_error_json_includes_recovery_hint_for_auth(capsys): """JSON error with E_AUTH includes the auth recovery_hint.""" args = SimpleNamespace(format="json") with pytest.raises(SystemExit): _emit_error("forbidden", args, error_code=E_AUTH) out, _ = capsys.readouterr() data = json.loads(out) assert data["error_code"] == E_AUTH assert "recovery_hint" in data hint = data["recovery_hint"].lower() assert "credential" in hint or "token" in hint def test_emit_error_json_no_recovery_hint_without_error_code(capsys): """No error_code -> no recovery_hint field (backwards compat).""" args = SimpleNamespace(format="json") with pytest.raises(SystemExit): _emit_error("something broke", args) out, _ = capsys.readouterr() data = json.loads(out) assert "error_code" not in data assert "recovery_hint" not in data