Files
wehub-resource-sync 7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

712 lines
23 KiB
Python

import logging
import os
import sys
import tempfile
import types
import shutil
import uuid
from pathlib import Path
from unittest.mock import Mock
import pytest
from loguru import logger
from sqlalchemy import create_engine, event
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import sessionmaker, Session
import local_deep_research.utilities.db_utils as db_utils_module
from local_deep_research.database.models import Base
from local_deep_research.database.auth_db import (
dispose_auth_engine,
init_auth_database,
)
from local_deep_research.web.app_factory import create_app
from local_deep_research.settings.manager import (
SettingsManager,
)
# Import our mock fixtures
try:
from .mock_fixtures import (
get_mock_arxiv_response,
get_mock_error_responses,
get_mock_findings,
get_mock_google_pse_response,
get_mock_ollama_response,
get_mock_pubmed_article,
get_mock_pubmed_response,
get_mock_research_history,
get_mock_search_results,
get_mock_semantic_scholar_response,
get_mock_settings,
get_mock_wikipedia_response,
)
except ImportError:
# Mock fixtures not yet created, skip for now
pass
def generate_unique_test_username(prefix: str = "pytest_user") -> str:
"""Generate unique username using UUID instead of timestamp.
This ensures no collisions when running tests in parallel with pytest-xdist.
"""
return f"{prefix}_{uuid.uuid4().hex[:12]}"
@pytest.fixture(autouse=True)
def reset_all_singletons():
"""Reset all singletons before and after each test.
This ensures proper isolation when running with pytest-xdist (-n auto).
Without this, singleton state can leak between tests running in parallel,
causing intermittent failures.
"""
def _reset():
# SocketIOService
try:
from local_deep_research.web.services.socket_service import (
SocketIOService,
)
SocketIOService._instance = None
except ImportError:
pass
# BackgroundJobScheduler (both class and module-level)
try:
from local_deep_research.scheduler import background
# Stop any running scheduler BEFORE dropping the singleton
# reference so its APScheduler thread doesn't emit logs to a
# closed pytest stderr sink during teardown.
if background.BackgroundJobScheduler._instance is not None:
try:
background.BackgroundJobScheduler._instance.stop()
except Exception:
# Never fail fixture teardown on scheduler edge cases.
pass
background.BackgroundJobScheduler._instance = None
# Also reset module-level global if it exists
if hasattr(background, "_scheduler_instance"):
background._scheduler_instance = None
except ImportError:
pass
# QueueProcessorV2 (module-level singleton thread). Like the
# scheduler above, create_app() starts this background thread, but
# nothing stopped it between tests — so the first test's processor
# ran for the whole worker, looping through SettingsManager ->
# SQLCipher connection opens on the shared db_manager concurrently
# with every later test (and emitting logs to a closed pytest
# stderr sink at teardown). Stop it here so each test starts clean;
# the next create_app() restarts it (stop() clears the running
# guard). Tests that exercise the queue patch this object, so
# stopping the real thread does not affect them.
try:
from local_deep_research.web.queue.processor_v2 import (
queue_processor,
)
queue_processor.stop()
except ImportError:
pass
except Exception:
# Never fail fixture teardown on queue-processor edge cases.
pass
# ProviderDiscovery - reset both class-level and module-level state
try:
from local_deep_research.llm.providers import auto_discovery
from local_deep_research.llm.providers.auto_discovery import (
ProviderDiscovery,
)
# Reset class-level singleton
ProviderDiscovery._instance = None
ProviderDiscovery._providers = {}
if hasattr(ProviderDiscovery, "_discovered"):
ProviderDiscovery._discovered = False
# Reset the module-level singleton instance's state
if hasattr(auto_discovery, "provider_discovery"):
auto_discovery.provider_discovery._discovered = False
auto_discovery.provider_discovery._providers = {}
# Re-register the built-in providers. get_llm() has no fallback
# construction path anymore, so a test that called
# clear_llm_registry() would otherwise break every later test
# that uses a built-in provider (order-dependent failures).
from local_deep_research.llm.providers import discover_providers
discover_providers(force_refresh=True)
except ImportError:
pass
# AccountLockoutManager singleton
try:
from local_deep_research.security import account_lockout
account_lockout._manager = None
except ImportError:
pass
# AdaptiveRateLimitTracker: no singleton to reset — get_tracker()
# returns a fresh instance each call.
_reset()
yield
_reset()
@pytest.fixture(autouse=True)
def database_operation_timeout():
"""Set shorter timeouts for database operations in tests.
This helps tests fail fast instead of hanging indefinitely when
database contention occurs.
"""
original = os.environ.get("LDR_DB_BUSY_TIMEOUT")
os.environ["LDR_DB_BUSY_TIMEOUT"] = "5000" # 5 seconds in tests
yield
if original:
os.environ["LDR_DB_BUSY_TIMEOUT"] = original
else:
os.environ.pop("LDR_DB_BUSY_TIMEOUT", None)
def pytest_configure(config):
"""Configure pytest with custom markers."""
config.addinivalue_line(
"markers",
"requires_llm: mark test as requiring a real LLM (skipped when LDR_TESTING_WITH_MOCKS=true)",
)
config.addinivalue_line(
"markers",
"integration: mark test as an integration test requiring live network access (skipped when LDR_TESTING_WITH_MOCKS=true)",
)
# In CI, LDR_TESTING_WITH_MOCKS is set via Docker environment variables
# For local testing, set it here if not already set
if not os.environ.get("LDR_TESTING_WITH_MOCKS"):
os.environ["LDR_TESTING_WITH_MOCKS"] = "true"
@pytest.fixture(autouse=True)
def skip_if_no_real_llm(request):
"""Skip tests marked with @pytest.mark.requires_llm when running with mocks."""
if request.node.get_closest_marker("requires_llm"):
if os.environ.get("LDR_TESTING_WITH_MOCKS", "").lower() == "true":
pytest.skip("Test requires real LLM but running with mocks")
@pytest.fixture(autouse=True)
def skip_integration_in_mock_mode(request):
"""Skip integration tests when running with mocks (CI default)."""
if request.node.get_closest_marker("integration"):
if os.environ.get("LDR_TESTING_WITH_MOCKS", "true").lower() == "true":
pytest.skip(
"Integration test skipped in mock mode "
"(set LDR_TESTING_WITH_MOCKS=false to run)"
)
@pytest.fixture
def temp_data_dir():
"""Create a temporary data directory for testing."""
temp_dir = tempfile.mkdtemp()
yield Path(temp_dir)
# Use ignore_errors=True because SQLite WAL/SHM files may still be held
# open by database connections that are disposed later in teardown order
# (cleanup_database_connections autouse fixture runs after this fixture).
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture(autouse=True)
def cleanup_database_connections():
"""Clean up database connections before and after each test.
This fixture ensures proper cleanup of database connections with
logging for debugging CI issues.
"""
# Import here to avoid circular imports
from local_deep_research.database.encrypted_db import db_manager
from local_deep_research.web.auth.session_manager import session_manager
# Close all connections and sessions before test
db_manager.close_all_databases()
session_manager.sessions.clear()
# Dispose auth engine so it will be recreated with correct path
dispose_auth_engine()
yield
# Close all connections and sessions after test
db_manager.close_all_databases()
session_manager.sessions.clear()
# Dispose auth engine after test
dispose_auth_engine()
@pytest.fixture
def app(temp_data_dir, monkeypatch):
"""Create a Flask app configured for testing."""
# Override data directory
monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir))
# Production default (256000) makes PBKDF2 dominate wall-clock in
# fixtures that register + log in users (authenticated_client runs
# it twice per test). Under xdist CPU contention from leaked auth
# background threads, setup blows past the 60s pytest-timeout and
# the worker dies — taking the rest of its tests with it and
# dropping coverage below the 50% gate. sqlcipher_utils declares
# MIN_KDF_ITERATIONS_TESTING=1 specifically to support this.
monkeypatch.setenv("LDR_DB_CONFIG_KDF_ITERATIONS", "1000")
# Note: PYTEST_CURRENT_TEST is automatically set by pytest, which
# app_factory.py checks to disable secure cookies for testing
# Create app with testing config
app, _ = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
app.config["WTF_CSRF_CHECK_DEFAULT"] = False
app.config["SESSION_COOKIE_SECURE"] = False # For testing without HTTPS
# Override PREFERRED_URL_SCHEME so test client defaults to HTTP, not HTTPS
# Tests that need HTTPS should explicitly set environ_base={"wsgi.url_scheme": "https"}
app.config["PREFERRED_URL_SCHEME"] = "http"
# Initialize auth database in test directory
init_auth_database()
return app
@pytest.fixture
def client(app):
"""Create a test client."""
return app.test_client()
@pytest.fixture
def app_with_csrf(temp_data_dir, monkeypatch):
"""Flask app with CSRF protection ENABLED.
The default `app` fixture disables CSRF for ergonomic testing of every
other code path; this opt-in fixture is for tests that specifically
verify CSRF token enforcement on mutating routes. Do not use for
general request testing — those tests should not be coupled to CSRF.
"""
monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir))
monkeypatch.setenv("LDR_DB_CONFIG_KDF_ITERATIONS", "1000")
app, _ = create_app()
app.config["TESTING"] = True
# Deliberately do NOT disable CSRF — this fixture exists to verify it.
app.config["SESSION_COOKIE_SECURE"] = False
app.config["PREFERRED_URL_SCHEME"] = "http"
init_auth_database()
return app
@pytest.fixture
def authenticated_client(app, temp_data_dir):
"""Create a test client with an authenticated user."""
# Create unique test username using UUID to avoid conflicts in parallel tests
test_username = generate_unique_test_username()
test_password = "TestPass123"
# Clear any existing user database
encrypted_db_dir = temp_data_dir / "encrypted_databases"
if encrypted_db_dir.exists():
import shutil
try:
shutil.rmtree(encrypted_db_dir)
except Exception as e:
logger.warning(f"Could not remove encrypted_db_dir: {e}")
# Create a test client
client = app.test_client()
# Register and login the user through the normal flow
with client:
# Register new unique user
register_response = client.post(
"/auth/register",
data={
"username": test_username,
"password": test_password,
"confirm_password": test_password,
"acknowledge": "true",
},
follow_redirects=False,
)
if register_response.status_code not in [200, 302]:
raise Exception(
f"Registration failed with status {register_response.status_code}: "
f"{register_response.data.decode()[:500]}"
)
# Login user
login_response = client.post(
"/auth/login",
data={"username": test_username, "password": test_password},
follow_redirects=False,
)
if login_response.status_code not in [200, 302]:
raise Exception(
f"Login failed with status {login_response.status_code}: "
f"{login_response.data.decode()[:500]}"
)
return client
@pytest.fixture()
def setup_database_for_all_tests(
tmp_path_factory, mocker
): # Use function-scoped mocker so patches don't leak to other tests
"""
Provides a database setup for a temporary SQLite file database for the entire test session.
It patches db_utils.get_db_session and db_utils.get_settings_manager to use this test DB.
"""
# Call cache_clear on the functions from db_utils_module.
# This ensures any pre-existing cached instances are gone.
# We must ensure db_utils_module is imported before this point.
try:
if hasattr(db_utils_module.get_db_session, "cache_clear"):
db_utils_module.get_db_session.cache_clear()
if hasattr(db_utils_module.get_settings_manager, "cache_clear"):
db_utils_module.get_settings_manager.cache_clear()
# get_setting_from_db_main_thread has been removed
except Exception as e:
logger.warning(f"Failed to clear db_utils caches aggressively: {e}")
# This shouldn't prevent test run, but indicates a problem with cache_clear
# Debug tmp_path_factory behavior
temp_dir = tmp_path_factory.mktemp("db_test_data")
db_file = temp_dir / "test_settings.db"
db_url = f"sqlite:///{db_file}"
engine = None
try:
engine = create_engine(db_url)
except Exception:
logger.exception("Failed to create SQLAlchemy engine")
raise
# Enable SQLite FK enforcement on every connection. Mirrors production
# (sqlcipher_utils.apply_performance_pragmas) and the FK-aware
# fixtures in tests/database/test_research_strategy_fk_regression.py
# and tests/database/test_chat_models.py. Fixture is function-scoped
# so the listener is registered fresh per test — no leakage.
# NB: parameter is named `dbapi` (not `dbapi_connection`) to sidestep
# the custom-checks raw-SQL detector, which uses an unanchored regex
# `conn.execute` that flags ANY identifier ending in "conn.execute".
# The PRAGMA is the canonical way to enable FK enforcement on SQLite
# and has no ORM equivalent.
@event.listens_for(engine, "connect")
def _enable_fk(dbapi, _connection_record):
dbapi.execute("PRAGMA foreign_keys = ON")
try:
Base.metadata.create_all(engine)
except SQLAlchemyError:
logger.exception("SQLAlchemyError during Base.metadata.create_all")
raise
except Exception:
logger.exception("Unexpected error during Base.metadata.create_all")
raise
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
temp_session = SessionLocal()
temp_settings_manager = SettingsManager(db_session=temp_session)
try:
temp_settings_manager.load_from_defaults_file(commit=True)
except Exception:
logger.exception("Failed to load default settings")
temp_session.rollback() # Rollback if default loading fails
raise # Re-raise to fail the test if default loading is critical
finally:
temp_session.close() # Close the temporary session used for loading defaults
# Clear caches and patch
db_utils_module.get_db_session.cache_clear()
mock_get_db_session = mocker.patch(
"local_deep_research.utilities.db_utils.get_db_session"
)
mock_get_db_session.side_effect = SessionLocal
mock_get_settings_manager = mocker.patch(
"local_deep_research.utilities.db_utils.get_settings_manager"
)
def _settings_with_maybe_fake_db(
db_session: Session | None = None, *_, **__
) -> SettingsManager:
if db_session is None:
# Use the mock.
db_session = mock_get_db_session()
return SettingsManager(db_session=db_session)
mock_get_settings_manager.side_effect = _settings_with_maybe_fake_db
yield SessionLocal # Yield the SessionLocal class for individual tests to create sessions
if engine:
engine.dispose() # Dispose the engine to close all connections
# tmp_path_factory handles deleting the temporary directory and its contents
@pytest.fixture
def mock_db_session(mocker):
return mocker.MagicMock()
@pytest.fixture
def mock_logger(mocker):
return mocker.patch("local_deep_research.settings.manager.logger")
# ============== LLM and Search Mock Fixtures (inspired by scottvr) ==============
@pytest.fixture
def mock_llm():
"""Create a mock LLM for testing."""
mock = Mock()
mock.invoke.return_value = Mock(content="Mocked LLM response")
return mock
@pytest.fixture
def mock_search():
"""Create a mock search engine for testing."""
mock = Mock()
mock.run.return_value = get_mock_search_results()
return mock
@pytest.fixture
def mock_search_system():
"""Create a mock search system for testing."""
mock = Mock()
mock.analyze_topic.return_value = get_mock_findings()
mock.all_links_of_system = [
{"title": "Source 1", "link": "https://example.com/1"},
{"title": "Source 2", "link": "https://example.com/2"},
]
return mock
# ============== API Response Mock Fixtures ==============
@pytest.fixture
def mock_wikipedia_response():
"""Mock response from Wikipedia API."""
return get_mock_wikipedia_response()
@pytest.fixture
def mock_arxiv_response():
"""Mock response from arXiv API."""
return get_mock_arxiv_response()
@pytest.fixture
def mock_pubmed_response():
"""Mock response from PubMed API."""
return get_mock_pubmed_response()
@pytest.fixture
def mock_pubmed_article():
"""Mock PubMed article detail."""
return get_mock_pubmed_article()
@pytest.fixture
def mock_semantic_scholar_response():
"""Mock response from Semantic Scholar API."""
return get_mock_semantic_scholar_response()
@pytest.fixture
def mock_google_pse_response():
"""Mock response from Google PSE API."""
return get_mock_google_pse_response()
@pytest.fixture
def mock_ollama_response():
"""Mock response from Ollama API."""
return get_mock_ollama_response()
# ============== Data Structure Mock Fixtures ==============
@pytest.fixture
def mock_search_results():
"""Sample search results for testing."""
return get_mock_search_results()
@pytest.fixture
def mock_findings():
"""Sample research findings for testing."""
return get_mock_findings()
@pytest.fixture
def mock_error_responses():
"""Collection of error responses for testing."""
return get_mock_error_responses()
# ============== Environment and Module Mock Fixtures ==============
@pytest.fixture
def mock_env_vars(monkeypatch):
"""Set up mock environment variables for testing."""
monkeypatch.setenv("LDR_LLM__PROVIDER", "test_provider")
monkeypatch.setenv("LDR_LLM__MODEL", "test_model")
monkeypatch.setenv("LDR_SEARCH__TOOL", "test_tool")
monkeypatch.setenv("LDR_SEARCH__ITERATIONS", "2")
yield
@pytest.fixture
def mock_llm_config(monkeypatch):
"""Create and patch a mock llm_config module."""
# Create a mock module
mock_module = types.ModuleType("mock_llm_config")
# Add necessary functions and variables
def get_llm(*args, **kwargs):
mock = Mock()
mock.invoke.return_value = Mock(content="Mocked LLM response")
return mock
mock_module.get_llm = get_llm
# Patch the module
monkeypatch.setitem(
sys.modules, "local_deep_research.config.llm_config", mock_module
)
monkeypatch.setattr("local_deep_research.config.llm_config", mock_module)
return mock_module
# ============== Test Database Fixtures ==============
@pytest.fixture
def temp_db_path():
"""Create a temporary database file for testing."""
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
yield path
os.unlink(path)
@pytest.fixture
def mock_research_history():
"""Mock research history entries."""
return get_mock_research_history()
@pytest.fixture
def mock_settings():
"""Mock settings configuration."""
return get_mock_settings()
# ============== Loguru Logging Fixtures ==============
class PropagateHandler(logging.Handler):
def emit(self, record):
logging.getLogger(record.name).handle(record)
@pytest.fixture
def loguru_caplog(caplog):
"""Make pytest caplog work with loguru.
Standard pytest caplog doesn't capture loguru logs out of the box.
This fixture propagates loguru logs to the standard logging module
so they can be captured by pytest's caplog fixture.
Note: The local_deep_research package disables loguru logging by default
(see src/local_deep_research/__init__.py). This fixture re-enables it
for the duration of the test.
See: https://loguru.readthedocs.io/en/stable/resources/migration.html
Usage:
def test_something(loguru_caplog):
import logging
with loguru_caplog.at_level(logging.WARNING):
# ... code that uses loguru logging ...
assert "expected message" in loguru_caplog.text
"""
# Re-enable logging for local_deep_research (disabled in __init__.py)
logger.enable("local_deep_research")
handler_id = logger.add(
PropagateHandler(),
format="{message}",
level="DEBUG",
# diagnose=False: loguru defaults to True, which dumps repr() of
# every traceback frame's local on exception. The propagating handler
# forwards those into pytest's stdlib log capture, where they end up
# in CI logs. Hygiene companion to #4185 / #4384.
diagnose=False,
)
yield caplog
logger.remove(handler_id)
# Re-disable logging to restore original state
logger.disable("local_deep_research")
@pytest.fixture
def loguru_caplog_full(caplog):
"""Like ``loguru_caplog`` but captures the exception block too.
Use this in security tests that assert a credential never appears in
log output. The default ``loguru_caplog`` fixture uses
``format="{message}"``, which excludes the rendered exception block
that ``logger.exception()`` (and ``exc_info=True``) emit — so a leak
that lives only in the traceback would false-pass.
This fixture appends ``{exception}`` to the format and enables
``backtrace=True`` so the cause chain (``__cause__`` / ``__context__``)
is rendered. ``diagnose`` stays off to match production
(``utilities/log_utils.py`` runs ``diagnose=False`` unless
``LDR_APP_DEBUG`` is set).
"""
logger.enable("local_deep_research")
handler_id = logger.add(
PropagateHandler(),
format="{message}\n{exception}",
level="DEBUG",
backtrace=True,
diagnose=False,
)
yield caplog
logger.remove(handler_id)
logger.disable("local_deep_research")