Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

638 lines
19 KiB
Python

"""
Comprehensive coverage tests for url_utils.normalize_url and
thread_context (set/get/clear, search_context manager, preserve_research_context).
This file is intentionally self-contained: every scenario listed in the task
specification is covered with distinct, well-named tests.
"""
import sys
import threading
from concurrent.futures import ThreadPoolExecutor
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.utilities.url_utils import normalize_url
from local_deep_research.utilities.thread_context import (
clear_search_context,
get_search_context,
preserve_research_context,
search_context,
set_search_context,
)
# ===================================================================
# PART 1 -- normalize_url
# ===================================================================
class TestNormalizeUrlLocalhostDefault:
"""The canonical example: localhost:11434 -> http://localhost:11434."""
def test_localhost_with_port(self):
assert normalize_url("localhost:11434") == "http://localhost:11434"
def test_localhost_bare(self):
assert normalize_url("localhost") == "http://localhost"
def test_localhost_with_path_and_port(self):
assert (
normalize_url("localhost:11434/api/generate")
== "http://localhost:11434/api/generate"
)
class TestNormalizeUrlMalformedScheme:
"""http:example.com (missing //) should be repaired."""
def test_http_colon_no_slashes(self):
assert normalize_url("http:example.com") == "http://example.com"
def test_https_colon_no_slashes(self):
assert normalize_url("https:example.com") == "https://example.com"
def test_http_colon_with_port(self):
assert (
normalize_url("http:example.com:8080") == "http://example.com:8080"
)
def test_https_colon_with_path(self):
assert (
normalize_url("https:example.com/api/v1")
== "https://example.com/api/v1"
)
def test_http_colon_localhost(self):
assert normalize_url("http:localhost:11434") == "http://localhost:11434"
class TestNormalizeUrlAlreadyCorrect:
"""URLs with proper scheme are returned unchanged."""
def test_http_url_unchanged(self):
assert (
normalize_url("http://localhost:11434") == "http://localhost:11434"
)
def test_https_url_unchanged(self):
url = "https://api.openai.com/v1/chat/completions"
assert normalize_url(url) == url
def test_http_with_path_query_fragment(self):
url = "http://example.com/path?q=1#frag"
assert normalize_url(url) == url
def test_https_with_userinfo(self):
url = "https://user:pass@host:9200/path"
assert normalize_url(url) == url
class TestNormalizeUrlIPv6:
"""IPv6 addresses in brackets."""
def test_ipv6_loopback(self):
assert normalize_url("[::1]") == "http://[::1]"
def test_ipv6_loopback_with_port(self):
assert normalize_url("[::1]:8080") == "http://[::1]:8080"
def test_ipv6_link_local(self):
result = normalize_url("[fe80::1]:8080")
assert result == "http://[fe80::1]:8080"
def test_ipv6_private_fc00(self):
result = normalize_url("[fc00::1]:9200")
assert result == "http://[fc00::1]:9200"
def test_ipv6_public(self):
result = normalize_url("[2001:4860:4860::8888]")
assert result.startswith("https://")
def test_ipv6_already_has_scheme(self):
url = "http://[::1]:11434"
assert normalize_url(url) == url
class TestNormalizeUrlPrivateIPs:
"""Private IPs get http://."""
@pytest.mark.parametrize(
"ip",
[
"10.0.0.1",
"10.255.255.255",
"172.16.0.1",
"172.31.255.255",
"192.168.0.1",
"192.168.1.100",
],
)
def test_rfc1918_gets_http(self, ip):
assert normalize_url(ip) == f"http://{ip}"
def test_private_ip_with_port(self):
assert (
normalize_url("192.168.1.100:8888") == "http://192.168.1.100:8888"
)
def test_zero_address(self):
assert normalize_url("0.0.0.0") == "http://0.0.0.0"
def test_127_0_0_1(self):
assert normalize_url("127.0.0.1") == "http://127.0.0.1"
def test_link_local_169_254(self):
assert normalize_url("169.254.0.1") == "http://169.254.0.1"
class TestNormalizeUrlExternalHosts:
"""External / public hosts get https://."""
@pytest.mark.parametrize(
"host",
[
"8.8.8.8",
"1.1.1.1",
"example.com",
"api.openai.com",
"sub.domain.example.org",
],
)
def test_public_host_gets_https(self, host):
assert normalize_url(host) == f"https://{host}"
def test_public_ip_with_port(self):
assert normalize_url("8.8.8.8:53") == "https://8.8.8.8:53"
class TestNormalizeUrlEmptyAndWhitespace:
"""Empty string and whitespace edge cases."""
def test_empty_string_raises(self):
with pytest.raises(ValueError, match="(?i)empty"):
normalize_url("")
def test_whitespace_stripped_leading_trailing(self):
assert normalize_url(" https://example.com ") == "https://example.com"
def test_whitespace_around_bare_host(self):
assert normalize_url(" localhost:8080 ") == "http://localhost:8080"
def test_tab_stripped(self):
assert normalize_url("\thttps://example.com\t") == "https://example.com"
def test_whitespace_only_does_not_raise(self):
# " " is truthy so it passes the empty check, but strips to ""
# which falls through to the scheme-adding logic
result = normalize_url(" ")
assert isinstance(result, str)
class TestNormalizeUrlDoubleSlash:
"""Protocol-relative //hostname format."""
def test_double_slash_public(self):
assert normalize_url("//example.com") == "https://example.com"
def test_double_slash_public_with_port(self):
assert normalize_url("//example.com:443") == "https://example.com:443"
def test_double_slash_localhost(self):
assert normalize_url("//localhost:8080") == "http://localhost:8080"
def test_double_slash_private_ip(self):
assert normalize_url("//192.168.1.1:9200") == "http://192.168.1.1:9200"
def test_double_slash_with_path(self):
assert (
normalize_url("//example.com/api/v1")
== "https://example.com/api/v1"
)
class TestNormalizeUrlMockedPrivateIp:
"""Mock-based tests to force both branches of the private-ip decision."""
def test_mock_private_gives_http(self):
with patch(
"local_deep_research.utilities.url_utils.is_private_ip",
return_value=True,
):
assert normalize_url("anyhost:1234") == "http://anyhost:1234"
def test_mock_public_gives_https(self):
with patch(
"local_deep_research.utilities.url_utils.is_private_ip",
return_value=False,
):
assert normalize_url("anyhost:1234") == "https://anyhost:1234"
def test_ipv6_hostname_passed_with_brackets(self):
"""Bracketed IPv6 passes bracket-inclusive hostname to is_private_ip."""
with patch(
"local_deep_research.utilities.url_utils.is_private_ip",
return_value=True,
) as mock_fn:
normalize_url("[::1]:8080")
mock_fn.assert_called_once_with("[::1]")
def test_bare_hostname_extracted_before_colon(self):
with patch(
"local_deep_research.utilities.url_utils.is_private_ip",
return_value=False,
) as mock_fn:
normalize_url("myhost:9999")
mock_fn.assert_called_once_with("myhost")
def test_bare_hostname_extracted_before_slash(self):
with patch(
"local_deep_research.utilities.url_utils.is_private_ip",
return_value=False,
) as mock_fn:
normalize_url("myhost/some/path")
mock_fn.assert_called_once_with("myhost")
# ===================================================================
# PART 2 -- thread_context
# ===================================================================
class TestSetSearchContext:
"""set_search_context sets context and overwrites previous."""
def setup_method(self):
clear_search_context()
def teardown_method(self):
clear_search_context()
def test_sets_context(self):
set_search_context({"research_id": "abc"})
assert get_search_context() == {"research_id": "abc"}
def test_overwrites_previous(self):
set_search_context({"first": 1})
set_search_context({"second": 2})
assert get_search_context() == {"second": 2}
def test_makes_defensive_copy(self):
original = {"key": "value"}
set_search_context(original)
original["key"] = "mutated"
assert get_search_context()["key"] == "value"
def test_empty_dict_accepted(self):
set_search_context({})
assert get_search_context() == {}
class TestClearSearchContext:
"""clear_search_context removes the context attribute entirely."""
def setup_method(self):
clear_search_context()
def teardown_method(self):
clear_search_context()
def test_clears_existing(self):
set_search_context({"x": 1})
clear_search_context()
assert get_search_context() is None
def test_noop_when_nothing_set(self):
clear_search_context() # must not raise
def test_clear_then_set_again(self):
set_search_context({"a": 1})
clear_search_context()
set_search_context({"b": 2})
assert get_search_context() == {"b": 2}
class TestGetSearchContext:
"""get_search_context returns a copy, or None when unset."""
def setup_method(self):
clear_search_context()
def teardown_method(self):
clear_search_context()
def test_returns_none_when_not_set(self):
assert get_search_context() is None
def test_returns_copy_not_reference(self):
set_search_context({"key": "value"})
result = get_search_context()
result["key"] = "mutated"
assert get_search_context()["key"] == "value"
def test_two_gets_are_independent_copies(self):
set_search_context({"k": "v"})
a = get_search_context()
b = get_search_context()
assert a == b
assert a is not b
def test_returns_correct_data(self):
set_search_context({"research_id": "r1", "extra": 42})
ctx = get_search_context()
assert ctx["research_id"] == "r1"
assert ctx["extra"] == 42
class TestSearchContextManager:
"""search_context context manager sets on entry and clears on exit."""
def setup_method(self):
clear_search_context()
def teardown_method(self):
clear_search_context()
def test_sets_inside_block(self):
with search_context({"research_id": "cm"}):
assert get_search_context() == {"research_id": "cm"}
def test_clears_after_block(self):
with search_context({"research_id": "cm2"}):
pass
assert get_search_context() is None
def test_clears_on_exception(self):
with pytest.raises(RuntimeError):
with search_context({"research_id": "err"}):
raise RuntimeError("boom")
assert get_search_context() is None
def test_copies_input_dict(self):
original = {"key": "val"}
with search_context(original):
original["key"] = "changed"
assert get_search_context()["key"] == "val"
def test_nested_inner_clears(self):
"""Inner context manager clears on exit (no stack)."""
with search_context({"level": "outer"}):
with search_context({"level": "inner"}):
assert get_search_context()["level"] == "inner"
# Inner exited, context is now gone
assert get_search_context() is None
class TestPreserveResearchContext:
"""preserve_research_context decorator captures and restores context."""
def setup_method(self):
clear_search_context()
# Evict the module so each test exercises a fresh lazy import; keep
# the original so teardown can put it back instead of leaving the
# NEXT importer to build a new singleton distinct from the one
# earlier-bound consumers hold.
self._saved_tls_module = sys.modules.pop(
"local_deep_research.database.thread_local_session", None
)
def teardown_method(self):
clear_search_context()
sys.modules.pop(
"local_deep_research.database.thread_local_session", None
)
db_pkg = sys.modules.get("local_deep_research.database")
if self._saved_tls_module is not None:
sys.modules["local_deep_research.database.thread_local_session"] = (
self._saved_tls_module
)
if db_pkg is not None:
db_pkg.thread_local_session = self._saved_tls_module
elif db_pkg is not None and hasattr(db_pkg, "thread_local_session"):
# The test's fresh import bound a throwaway module on the parent
# package; drop it so attribute lookups can't resolve to it.
delattr(db_pkg, "thread_local_session")
def test_captures_context_at_decoration_time(self):
set_search_context({"research_id": "snap"})
@preserve_research_context
def worker():
return get_search_context()
# Clear the context on the main thread after decoration
clear_search_context()
# The decorator captured the snapshot; running in another thread
# should still see it.
with ThreadPoolExecutor(max_workers=1) as pool:
result = pool.submit(worker).result()
assert result == {"research_id": "snap"}
def test_restores_and_clears_in_worker_thread(self):
set_search_context({"research_id": "restore"})
context_after = []
@preserve_research_context
def worker():
return "done"
def run_then_check():
worker()
context_after.append(get_search_context())
clear_search_context()
with ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(run_then_check).result()
# Context should have been cleared after the worker finished
assert context_after[0] is None
def test_no_context_runs_without_error(self):
"""When no context was set at decoration time, runs normally."""
assert get_search_context() is None
@preserve_research_context
def worker():
return "ok"
assert worker() == "ok"
def test_preserves_function_name(self):
@preserve_research_context
def my_func():
"""Docstring."""
pass
assert my_func.__name__ == "my_func"
assert my_func.__doc__ == "Docstring."
def test_passes_args_and_kwargs(self):
set_search_context({"research_id": "args"})
@preserve_research_context
def add(a, b, offset=0):
return a + b + offset
clear_search_context()
with ThreadPoolExecutor(max_workers=1) as pool:
result = pool.submit(add, 3, 4, offset=10).result()
assert result == 17
def test_exception_propagates(self):
set_search_context({"research_id": "exc"})
@preserve_research_context
def boom():
raise ValueError("kaboom")
clear_search_context()
with pytest.raises(ValueError, match="kaboom"):
boom()
def test_clears_context_even_on_exception(self):
set_search_context({"research_id": "exc2"})
context_after = []
@preserve_research_context
def boom():
raise ValueError("kaboom")
clear_search_context()
def run_then_check():
try:
boom()
except ValueError:
pass
context_after.append(get_search_context())
with ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(run_then_check).result()
assert context_after[0] is None
def test_cleanup_current_thread_called(self):
"""The finally block should call cleanup_current_thread."""
set_search_context({"research_id": "cleanup"})
@preserve_research_context
def worker():
return 42
clear_search_context()
mock_cleanup = MagicMock()
fake_module = ModuleType(
"local_deep_research.database.thread_local_session"
)
fake_module.cleanup_current_thread = mock_cleanup
with patch.dict(
sys.modules,
{
"local_deep_research.database.thread_local_session": (
fake_module
)
},
):
result = worker()
assert result == 42
mock_cleanup.assert_called_once()
def test_cleanup_exception_is_suppressed(self):
"""If cleanup_current_thread raises, the error is swallowed."""
set_search_context({"research_id": "suppress"})
@preserve_research_context
def worker():
return "fine"
clear_search_context()
fake_module = ModuleType(
"local_deep_research.database.thread_local_session"
)
fake_module.cleanup_current_thread = MagicMock(
side_effect=RuntimeError("db engine died")
)
with patch.dict(
sys.modules,
{
"local_deep_research.database.thread_local_session": (
fake_module
)
},
):
result = worker()
assert result == "fine"
def test_cleanup_import_failure_suppressed(self):
"""If thread_local_session module cannot be imported, no crash."""
set_search_context({"research_id": "noimport"})
@preserve_research_context
def worker():
return "still fine"
clear_search_context()
with patch.dict(
sys.modules,
{"local_deep_research.database.thread_local_session": None},
):
assert worker() == "still fine"
class TestThreadIsolation:
"""Context is truly thread-local: other threads cannot see it."""
def setup_method(self):
clear_search_context()
def teardown_method(self):
clear_search_context()
def test_other_thread_sees_none(self):
set_search_context({"main": True})
result = []
def check():
result.append(get_search_context())
t = threading.Thread(target=check)
t.start()
t.join()
assert result[0] is None
def test_threads_have_independent_contexts(self):
results = {}
def worker(tid):
set_search_context({"tid": tid})
ctx = get_search_context()
results[tid] = ctx["tid"]
threads = [threading.Thread(target=worker, args=(i,)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
for i in range(5):
assert results[i] == i