"""Tests for v2.4.0 E_BLOCKED error classification (fetch scenario). Real-world motivation: fetching baike.baidu.com etc. returns 403 because the site blocks automated access (WAF/anti-bot), not because credentials are wrong. classify_error maps 401/403 -> E_AUTH (correct for SearXNG instance auth), but for page fetches that misleads agents into checking credentials. classify_fetch_error narrows fetch-scenario 403 -> E_BLOCKED. Covers: * classify_fetch_error 403/401/5xx/4xx + message-only fallback * classify_error keeps 403 -> E_AUTH (search scenario untouched) * fetch_page error_code uses the fetch-scenario classification """ import urllib.error from unittest.mock import patch import pytest from common import ( E_AUTH, E_BLOCKED, E_INPUT, E_NETWORK, classify_error, classify_fetch_error, ) import search as search_mod from search import fetch_page def _http_error(code): try: raise urllib.error.HTTPError( "https://x.example.com", code, "Error", {}, None) except urllib.error.HTTPError as e: return e # ===== classify_fetch_error ===== def test_fetch_403_is_blocked(): """Fetch 403 = anti-bot block, NOT auth.""" assert classify_fetch_error(_http_error(403)) == E_BLOCKED def test_fetch_401_is_auth(): """Fetch 401 stays E_AUTH (real credentials problem).""" assert classify_fetch_error(_http_error(401)) == E_AUTH def test_fetch_5xx_delegates_to_network(): assert classify_fetch_error(_http_error(503)) == E_NETWORK def test_fetch_404_delegates_to_input(): """4xx other than 403/401 keeps classify_error's E_INPUT.""" assert classify_fetch_error(_http_error(404)) == E_INPUT def test_fetch_runtime_error_message_fallback(): """No __cause__ but message contains 'HTTP 403' -> E_BLOCKED.""" err = RuntimeError("HTTP 403 for https://x.example.com") assert classify_fetch_error(err) == E_BLOCKED def test_fetch_explicit_status_code_shortcut(): """Caller-provided status_code avoids re-parsing the exception.""" assert classify_fetch_error(RuntimeError("no code in message"), status_code=403) == E_BLOCKED def test_fetch_generic_error_delegates(): """Connection-level exceptions delegate to classify_error -> E_NETWORK.""" err = urllib.error.URLError("connection refused") assert classify_fetch_error(err) == E_NETWORK # ===== classify_error unchanged (search scenario) ===== def test_classify_error_403_still_auth(): """SearXNG instance 403 remains E_AUTH — search scenario untouched.""" assert classify_error(_http_error(403)) == E_AUTH # ===== fetch_page integration ===== def test_fetch_page_403_error_code_blocked(): """fetch_page reports 403 as E_BLOCKED (not E_AUTH).""" with patch.object(search_mod, "fetch_url", side_effect=RuntimeError("HTTP 403 for url")): r = fetch_page("https://blocked.example.com", fallback_enabled=False) assert r["status"] == "error" assert r["error_code"] == E_BLOCKED assert "403" in r["error"] def test_fetch_page_404_error_code_input(): """fetch_page 404 stays E_INPUT (not blocked/auth).""" with patch.object(search_mod, "fetch_url", side_effect=RuntimeError("HTTP 404 for url")): r = fetch_page("https://missing.example.com", fallback_enabled=False) assert r["status"] == "error" assert r["error_code"] == E_INPUT def test_fetch_page_401_error_code_auth(): """fetch_page 401 stays E_AUTH (genuine credentials problem).""" with patch.object(search_mod, "fetch_url", side_effect=RuntimeError("HTTP 401 for url")): r = fetch_page("https://private.example.com", fallback_enabled=False) assert r["status"] == "error" assert r["error_code"] == E_AUTH