413 lines
16 KiB
Python
413 lines
16 KiB
Python
"""Tests for ccproxy_manager module."""
|
|
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from EvoScientist.ccproxy_manager import (
|
|
check_ccproxy_auth,
|
|
ensure_ccproxy,
|
|
is_ccproxy_available,
|
|
is_ccproxy_running,
|
|
maybe_start_ccproxy,
|
|
setup_ccproxy_env,
|
|
setup_codex_env,
|
|
start_ccproxy,
|
|
stop_ccproxy,
|
|
)
|
|
|
|
# =============================================================================
|
|
# is_ccproxy_available
|
|
# =============================================================================
|
|
|
|
|
|
class TestIsCcproxyAvailable:
|
|
@patch("shutil.which", return_value="/usr/local/bin/ccproxy")
|
|
def test_found(self, mock_which):
|
|
assert is_ccproxy_available() is True
|
|
mock_which.assert_called_once_with("ccproxy")
|
|
|
|
@patch("os.access", return_value=False)
|
|
@patch("os.path.isfile", return_value=False)
|
|
@patch("shutil.which", return_value=None)
|
|
def test_not_found(self, mock_which, mock_isfile, mock_access):
|
|
assert is_ccproxy_available() is False
|
|
|
|
|
|
# =============================================================================
|
|
# check_ccproxy_auth
|
|
# =============================================================================
|
|
|
|
|
|
class TestCheckCcproxyAuth:
|
|
@patch("subprocess.run")
|
|
def test_valid_auth(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
returncode=0, stdout="Authenticated as user@example.com", stderr=""
|
|
)
|
|
valid, msg = check_ccproxy_auth()
|
|
assert valid is True
|
|
assert "Authenticated" in msg
|
|
mock_run.assert_called_once()
|
|
cmd = mock_run.call_args[0][0]
|
|
assert cmd[1:] == ["auth", "status", "claude_api"]
|
|
|
|
@patch("subprocess.run")
|
|
def test_valid_auth_codex(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
returncode=0, stdout="Authenticated", stderr=""
|
|
)
|
|
valid, _msg = check_ccproxy_auth("codex")
|
|
assert valid is True
|
|
cmd = mock_run.call_args[0][0]
|
|
assert cmd[1:] == ["auth", "status", "codex"]
|
|
|
|
@patch("subprocess.run")
|
|
def test_invalid_auth(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
returncode=1, stdout="", stderr="Not authenticated"
|
|
)
|
|
valid, msg = check_ccproxy_auth()
|
|
assert valid is False
|
|
assert "Not authenticated" in msg
|
|
|
|
@patch("subprocess.run", side_effect=FileNotFoundError)
|
|
def test_missing_binary(self, mock_run):
|
|
valid, msg = check_ccproxy_auth()
|
|
assert valid is False
|
|
assert "not found" in msg
|
|
|
|
|
|
# =============================================================================
|
|
# is_ccproxy_running
|
|
# =============================================================================
|
|
|
|
|
|
class TestIsCcproxyRunning:
|
|
@patch("httpx.get")
|
|
def test_running(self, mock_get):
|
|
mock_get.return_value = MagicMock(status_code=200)
|
|
assert is_ccproxy_running(8000) is True
|
|
|
|
@patch("httpx.get")
|
|
def test_uses_health_live_endpoint(self, mock_get):
|
|
mock_get.return_value = MagicMock(status_code=200)
|
|
is_ccproxy_running(8000)
|
|
url = mock_get.call_args[0][0]
|
|
assert url == "http://127.0.0.1:8000/health/live"
|
|
|
|
@patch("httpx.get")
|
|
def test_uses_custom_port(self, mock_get):
|
|
mock_get.return_value = MagicMock(status_code=200)
|
|
is_ccproxy_running(7777)
|
|
url = mock_get.call_args[0][0]
|
|
assert url == "http://127.0.0.1:7777/health/live"
|
|
|
|
@patch("httpx.get")
|
|
def test_not_running(self, mock_get):
|
|
import httpx
|
|
|
|
mock_get.side_effect = httpx.ConnectError("Connection refused")
|
|
assert is_ccproxy_running(8000) is False
|
|
|
|
@patch("httpx.get")
|
|
def test_non_200_means_not_running(self, mock_get):
|
|
mock_get.return_value = MagicMock(status_code=404)
|
|
assert is_ccproxy_running(8000) is False
|
|
|
|
|
|
# =============================================================================
|
|
# start_ccproxy
|
|
# =============================================================================
|
|
|
|
|
|
class TestStartCcproxy:
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_running")
|
|
@patch("subprocess.Popen")
|
|
def test_success(self, mock_popen, mock_running):
|
|
proc = MagicMock()
|
|
proc.poll.return_value = None
|
|
mock_popen.return_value = proc
|
|
# First call: not running, second call: running
|
|
mock_running.side_effect = [True]
|
|
|
|
result = start_ccproxy(8000)
|
|
assert result is proc
|
|
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_running", return_value=False)
|
|
@patch("EvoScientist.ccproxy_manager.time")
|
|
@patch("subprocess.Popen")
|
|
def test_timeout(self, mock_popen, mock_time, mock_running):
|
|
proc = MagicMock()
|
|
proc.poll.return_value = None
|
|
mock_popen.return_value = proc
|
|
# Simulate time passing beyond deadline
|
|
mock_time.monotonic.side_effect = [0, 0, 31]
|
|
mock_time.sleep = MagicMock()
|
|
|
|
with pytest.raises(RuntimeError, match="did not become healthy"):
|
|
start_ccproxy(8000)
|
|
|
|
@patch("subprocess.Popen", side_effect=FileNotFoundError)
|
|
def test_missing_binary(self, mock_popen):
|
|
with pytest.raises(FileNotFoundError):
|
|
start_ccproxy(8000)
|
|
|
|
|
|
# =============================================================================
|
|
# ensure_ccproxy
|
|
# =============================================================================
|
|
|
|
|
|
class TestEnsureCcproxy:
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_running", return_value=True)
|
|
def test_already_running(self, mock_running):
|
|
result = ensure_ccproxy(8000)
|
|
assert result is None
|
|
|
|
@patch("EvoScientist.ccproxy_manager.start_ccproxy")
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_running", return_value=False)
|
|
def test_needs_start(self, mock_running, mock_start):
|
|
proc = MagicMock()
|
|
mock_start.return_value = proc
|
|
result = ensure_ccproxy(8000)
|
|
assert result is proc
|
|
mock_start.assert_called_once_with(8000)
|
|
|
|
|
|
# =============================================================================
|
|
# setup_ccproxy_env
|
|
# =============================================================================
|
|
|
|
|
|
class TestSetupCcproxyEnv:
|
|
def test_sets_vars(self, monkeypatch):
|
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
|
|
|
setup_ccproxy_env(8000)
|
|
|
|
assert os.environ["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8000/claude"
|
|
assert os.environ["ANTHROPIC_API_KEY"] == "ccproxy-oauth"
|
|
|
|
def test_overrides_existing(self, monkeypatch):
|
|
"""Force-sets vars even if already configured (oauth takes priority)."""
|
|
monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://custom:9999")
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-real-key")
|
|
|
|
setup_ccproxy_env(8000)
|
|
|
|
assert os.environ["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8000/claude"
|
|
assert os.environ["ANTHROPIC_API_KEY"] == "ccproxy-oauth"
|
|
|
|
|
|
# =============================================================================
|
|
# setup_codex_env
|
|
# =============================================================================
|
|
|
|
|
|
class TestSetupCodexEnv:
|
|
def test_sets_vars(self, monkeypatch):
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
|
|
setup_codex_env(8000)
|
|
|
|
assert os.environ["OPENAI_BASE_URL"] == "http://127.0.0.1:8000/codex/v1"
|
|
assert os.environ["OPENAI_API_KEY"] == "ccproxy-oauth"
|
|
|
|
def test_overrides_existing(self, monkeypatch):
|
|
"""Force-sets vars even if already configured (oauth takes priority)."""
|
|
monkeypatch.setenv("OPENAI_BASE_URL", "http://custom:9999")
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key")
|
|
|
|
setup_codex_env(8000)
|
|
|
|
assert os.environ["OPENAI_BASE_URL"] == "http://127.0.0.1:8000/codex/v1"
|
|
assert os.environ["OPENAI_API_KEY"] == "ccproxy-oauth"
|
|
|
|
|
|
# =============================================================================
|
|
# stop_ccproxy
|
|
# =============================================================================
|
|
|
|
|
|
class TestStopCcproxy:
|
|
def test_none_is_noop(self):
|
|
stop_ccproxy(None) # Should not raise
|
|
|
|
def test_terminates_process(self):
|
|
proc = MagicMock()
|
|
stop_ccproxy(proc)
|
|
proc.terminate.assert_called_once()
|
|
proc.wait.assert_called_once_with(timeout=5)
|
|
|
|
|
|
# =============================================================================
|
|
# maybe_start_ccproxy
|
|
# =============================================================================
|
|
|
|
|
|
class TestMaybeStartCcproxy:
|
|
def test_api_key_mode_noop(self):
|
|
config = MagicMock()
|
|
config.anthropic_auth_mode = "api_key"
|
|
config.openai_auth_mode = "api_key"
|
|
assert maybe_start_ccproxy(config) is None
|
|
|
|
@patch("EvoScientist.ccproxy_manager.setup_ccproxy_env")
|
|
@patch("EvoScientist.ccproxy_manager.ensure_ccproxy")
|
|
@patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK"))
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
|
|
def test_oauth_mode_starts(self, mock_avail, mock_auth, mock_ensure, mock_env):
|
|
proc = MagicMock()
|
|
mock_ensure.return_value = proc
|
|
config = MagicMock()
|
|
config.anthropic_auth_mode = "oauth"
|
|
config.openai_auth_mode = "api_key"
|
|
config.ccproxy_port = 8000
|
|
|
|
result = maybe_start_ccproxy(config)
|
|
assert result is proc
|
|
mock_env.assert_called_once()
|
|
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=False)
|
|
def test_oauth_mode_raises_no_binary(self, mock_avail):
|
|
config = MagicMock()
|
|
config.anthropic_auth_mode = "oauth"
|
|
config.openai_auth_mode = "api_key"
|
|
with pytest.raises(RuntimeError, match="not found"):
|
|
maybe_start_ccproxy(config)
|
|
|
|
@patch(
|
|
"EvoScientist.ccproxy_manager.check_ccproxy_auth",
|
|
return_value=(False, "expired"),
|
|
)
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
|
|
def test_oauth_mode_raises_no_auth(self, mock_avail, mock_auth):
|
|
config = MagicMock()
|
|
config.anthropic_auth_mode = "oauth"
|
|
config.openai_auth_mode = "api_key"
|
|
with pytest.raises(RuntimeError, match="not authenticated"):
|
|
maybe_start_ccproxy(config)
|
|
|
|
@patch("EvoScientist.ccproxy_manager.setup_codex_env")
|
|
@patch("EvoScientist.ccproxy_manager.ensure_ccproxy")
|
|
@patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK"))
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
|
|
def test_openai_oauth_mode_starts(
|
|
self, mock_avail, mock_auth, mock_ensure, mock_env
|
|
):
|
|
proc = MagicMock()
|
|
mock_ensure.return_value = proc
|
|
config = MagicMock()
|
|
config.anthropic_auth_mode = "api_key"
|
|
config.openai_auth_mode = "oauth"
|
|
config.ccproxy_port = 8000
|
|
|
|
result = maybe_start_ccproxy(config)
|
|
assert result is proc
|
|
mock_auth.assert_called_once_with("codex")
|
|
mock_env.assert_called_once()
|
|
|
|
@patch("EvoScientist.ccproxy_manager.setup_codex_env")
|
|
@patch("EvoScientist.ccproxy_manager.setup_ccproxy_env")
|
|
@patch("EvoScientist.ccproxy_manager.ensure_ccproxy")
|
|
@patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK"))
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
|
|
def test_both_oauth_starts_both(
|
|
self, mock_avail, mock_auth, mock_ensure, mock_anthropic_env, mock_codex_env
|
|
):
|
|
proc = MagicMock()
|
|
mock_ensure.return_value = proc
|
|
config = MagicMock()
|
|
config.anthropic_auth_mode = "oauth"
|
|
config.openai_auth_mode = "oauth"
|
|
config.ccproxy_port = 8000
|
|
|
|
result = maybe_start_ccproxy(config)
|
|
assert result is proc
|
|
# Auth checked for both providers
|
|
assert mock_auth.call_count == 2
|
|
mock_anthropic_env.assert_called_once()
|
|
mock_codex_env.assert_called_once()
|
|
|
|
@patch("EvoScientist.ccproxy_manager._is_editable_install", return_value=True)
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=False)
|
|
def test_openai_oauth_raises_no_binary_editable(self, mock_avail, mock_edit):
|
|
config = MagicMock()
|
|
config.anthropic_auth_mode = "api_key"
|
|
config.openai_auth_mode = "oauth"
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
maybe_start_ccproxy(config)
|
|
msg = str(exc_info.value)
|
|
assert "ccproxy is required for OAuth mode but not found" in msg
|
|
assert "uv sync --extra oauth" in msg
|
|
assert "pip install -e '.[oauth]'" in msg
|
|
|
|
@patch("EvoScientist.ccproxy_manager._is_editable_install", return_value=False)
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=False)
|
|
def test_openai_oauth_raises_no_binary_pip(self, mock_avail, mock_edit):
|
|
config = MagicMock()
|
|
config.anthropic_auth_mode = "api_key"
|
|
config.openai_auth_mode = "oauth"
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
maybe_start_ccproxy(config)
|
|
msg = str(exc_info.value)
|
|
assert "ccproxy is required for OAuth mode but not found" in msg
|
|
assert "pip install 'evoscientist[oauth]'" in msg
|
|
|
|
@patch(
|
|
"EvoScientist.ccproxy_manager.check_ccproxy_auth",
|
|
return_value=(False, "expired"),
|
|
)
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
|
|
def test_openai_oauth_raises_no_auth(self, mock_avail, mock_auth):
|
|
config = MagicMock()
|
|
config.anthropic_auth_mode = "api_key"
|
|
config.openai_auth_mode = "oauth"
|
|
with pytest.raises(RuntimeError, match="Codex OAuth not authenticated"):
|
|
maybe_start_ccproxy(config)
|
|
|
|
@patch("EvoScientist.ccproxy_manager.setup_ccproxy_env")
|
|
@patch("EvoScientist.ccproxy_manager.ensure_ccproxy")
|
|
@patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK"))
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
|
|
def test_uses_config_ccproxy_port(
|
|
self, mock_avail, mock_auth, mock_ensure, mock_env
|
|
):
|
|
"""maybe_start_ccproxy passes config.ccproxy_port to ensure_ccproxy."""
|
|
proc = MagicMock()
|
|
mock_ensure.return_value = proc
|
|
config = MagicMock()
|
|
config.anthropic_auth_mode = "oauth"
|
|
config.openai_auth_mode = "api_key"
|
|
config.ccproxy_port = 7777
|
|
|
|
maybe_start_ccproxy(config)
|
|
mock_ensure.assert_called_once_with(7777)
|
|
|
|
@patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK"))
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
|
|
def test_invalid_port_raises(self, mock_avail, mock_auth):
|
|
"""maybe_start_ccproxy raises ValueError for out-of-range port."""
|
|
config = MagicMock()
|
|
config.anthropic_auth_mode = "oauth"
|
|
config.openai_auth_mode = "api_key"
|
|
config.ccproxy_port = 0
|
|
|
|
with pytest.raises(ValueError, match="Invalid ccproxy port"):
|
|
maybe_start_ccproxy(config)
|
|
|
|
@patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK"))
|
|
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
|
|
def test_port_too_large_raises(self, mock_avail, mock_auth):
|
|
config = MagicMock()
|
|
config.anthropic_auth_mode = "oauth"
|
|
config.openai_auth_mode = "api_key"
|
|
config.ccproxy_port = 99999
|
|
|
|
with pytest.raises(ValueError, match="Invalid ccproxy port"):
|
|
maybe_start_ccproxy(config)
|