diff --git a/pyproject.toml b/pyproject.toml index b7dc3ce7..856a8941 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,10 +43,18 @@ include = ["tradingagents*", "cli*"] [dependency-groups] dev = [ "pytest>=9.0.2", + "pytest-recording>=0.13.2", + "vcrpy>=6.0.2", + "pytest-socket>=0.7.0", ] [tool.pytest.ini_options] +testpaths = ["tests"] markers = [ - "integration: marks tests as live integration tests requiring real API keys", + "integration: tests that replay VCR cassettes of data API calls", + "e2e: tests that hit real LLM APIs (manual trigger only)", + "vcr: tests that use VCR cassette recording", + "slow: tests that take a long time to run", "paid_tier: marks tests that require a paid Finnhub subscription (free tier returns HTTP 403)", ] +addopts = "--ignore=tests/integration --ignore=tests/e2e --disable-socket --allow-unix-socket -x -q" diff --git a/tests/cassettes/.gitkeep b/tests/cassettes/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 00000000..614c2427 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,10 @@ +"""E2E test configuration — real LLM API calls, manual trigger only.""" + +import pytest + + +def pytest_collection_modifyitems(config, items): + """Mark all e2e tests as slow.""" + for item in items: + item.add_marker(pytest.mark.e2e) + item.add_marker(pytest.mark.slow) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 00000000..252f360a --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,29 @@ +"""Integration test configuration — VCR cassette replay for data API tests.""" + +import os +import pytest + + +@pytest.fixture(scope="module") +def vcr_config(): + return { + "cassette_library_dir": "tests/cassettes", + "record_mode": "none", + "match_on": ["method", "scheme", "host", "port", "path"], + "filter_headers": [ + "Authorization", + "Cookie", + "X-Api-Key", + ], + "filter_query_parameters": [ + "apikey", + "token", + ], + "decode_compressed_response": True, + } + + +@pytest.fixture +def av_api_key(): + """Return the Alpha Vantage API key for integration tests.""" + return os.environ.get("ALPHA_VANTAGE_API_KEY", "demo") diff --git a/tests/test_finnhub_live_integration.py b/tests/integration/test_finnhub_live.py similarity index 100% rename from tests/test_finnhub_live_integration.py rename to tests/integration/test_finnhub_live.py diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..44db32cc --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,73 @@ +"""Shared mock factories for unit tests. + +Network is blocked by pytest-socket (--disable-socket in addopts). +No test in tests/unit/ can hit a real API. +""" + +import json +import pandas as pd +import pytest +from unittest.mock import MagicMock + + +# -- yfinance mock factories -- + + +@pytest.fixture +def mock_yf_screener(): + """Pre-built yfinance screener mock.""" + + def _make(quotes): + return {"quotes": quotes} + + return _make + + +@pytest.fixture +def mock_yf_download(): + """Pre-built yfinance download mock returning a MultiIndex DataFrame.""" + + def _make(symbols, periods=5, base_price=100.0): + idx = pd.date_range("2024-01-04", periods=periods, freq="B") + data = {s: [base_price + i for i in range(periods)] for s in symbols} + df = pd.DataFrame(data, index=idx) + df.columns = pd.MultiIndex.from_product([["Close"], symbols]) + return df + + return _make + + +# -- Alpha Vantage mock factories -- + + +@pytest.fixture +def mock_av_request(): + """Pre-built Alpha Vantage _rate_limited_request mock.""" + + def _make(responses: dict): + """responses: {function_name: return_value} or callable.""" + + def fake(function_name, params=None, **kwargs): + if callable(responses.get(function_name)): + return responses[function_name](params) + return json.dumps(responses.get(function_name, {})) + + return fake + + return _make + + +# -- LLM mock factories -- + + +@pytest.fixture +def mock_llm(): + """Pre-built LLM mock that returns canned responses.""" + + def _make(content="Mocked LLM response."): + llm = MagicMock() + llm.invoke.return_value = MagicMock(content=content) + llm.ainvoke.return_value = MagicMock(content=content) + return llm + + return _make diff --git a/tests/test_alpha_vantage_exceptions.py b/tests/unit/test_alpha_vantage_exceptions.py similarity index 100% rename from tests/test_alpha_vantage_exceptions.py rename to tests/unit/test_alpha_vantage_exceptions.py diff --git a/tests/test_alpha_vantage_integration.py b/tests/unit/test_alpha_vantage_integration.py similarity index 100% rename from tests/test_alpha_vantage_integration.py rename to tests/unit/test_alpha_vantage_integration.py diff --git a/tests/test_alpha_vantage_scanner.py b/tests/unit/test_alpha_vantage_scanner.py similarity index 100% rename from tests/test_alpha_vantage_scanner.py rename to tests/unit/test_alpha_vantage_scanner.py diff --git a/tests/test_config_wiring.py b/tests/unit/test_config_wiring.py similarity index 100% rename from tests/test_config_wiring.py rename to tests/unit/test_config_wiring.py diff --git a/tests/test_debate_rounds.py b/tests/unit/test_debate_rounds.py similarity index 100% rename from tests/test_debate_rounds.py rename to tests/unit/test_debate_rounds.py diff --git a/tests/test_e2e_api_integration.py b/tests/unit/test_e2e_api_integration.py similarity index 100% rename from tests/test_e2e_api_integration.py rename to tests/unit/test_e2e_api_integration.py diff --git a/tests/test_env_override.py b/tests/unit/test_env_override.py similarity index 100% rename from tests/test_env_override.py rename to tests/unit/test_env_override.py diff --git a/tests/test_finnhub_integration.py b/tests/unit/test_finnhub_integration.py similarity index 100% rename from tests/test_finnhub_integration.py rename to tests/unit/test_finnhub_integration.py diff --git a/tests/test_json_utils.py b/tests/unit/test_json_utils.py similarity index 100% rename from tests/test_json_utils.py rename to tests/unit/test_json_utils.py diff --git a/tests/test_macro_bridge.py b/tests/unit/test_macro_bridge.py similarity index 100% rename from tests/test_macro_bridge.py rename to tests/unit/test_macro_bridge.py diff --git a/tests/test_macro_regime.py b/tests/unit/test_macro_regime.py similarity index 100% rename from tests/test_macro_regime.py rename to tests/unit/test_macro_regime.py diff --git a/tests/test_peer_comparison.py b/tests/unit/test_peer_comparison.py similarity index 100% rename from tests/test_peer_comparison.py rename to tests/unit/test_peer_comparison.py diff --git a/tests/test_scanner_graph.py b/tests/unit/test_scanner_graph.py similarity index 100% rename from tests/test_scanner_graph.py rename to tests/unit/test_scanner_graph.py diff --git a/tests/test_scanner_mocked.py b/tests/unit/test_scanner_mocked.py similarity index 100% rename from tests/test_scanner_mocked.py rename to tests/unit/test_scanner_mocked.py diff --git a/tests/test_scanner_routing.py b/tests/unit/test_scanner_routing.py similarity index 100% rename from tests/test_scanner_routing.py rename to tests/unit/test_scanner_routing.py diff --git a/tests/test_ttm_analysis.py b/tests/unit/test_ttm_analysis.py similarity index 100% rename from tests/test_ttm_analysis.py rename to tests/unit/test_ttm_analysis.py diff --git a/tests/test_vendor_failfast.py b/tests/unit/test_vendor_failfast.py similarity index 100% rename from tests/test_vendor_failfast.py rename to tests/unit/test_vendor_failfast.py diff --git a/tests/test_yfinance_integration.py b/tests/unit/test_yfinance_integration.py similarity index 100% rename from tests/test_yfinance_integration.py rename to tests/unit/test_yfinance_integration.py