* feat(embed): add hindisght-embed profiles * ci: run pytest tests for hindsight-embed in CI - Add pytest test run step to test-embed job - This ensures profile tests (37 tests) are run in CI - Smoke test still runs after pytest tests * feat(embed): use 'default' profile name consistently - Configure command now shows "Profile 'default' configured successfully!" - Profile list shows "default" instead of empty string - Profile show displays "default" consistently - All output now uses "default" label for backward-compatible config - Added port display for default profile in all commands * fix(embed): replace requests with httpx in profile_manager - Use httpx.Client() instead of requests.get() for daemon health check - Update test mock to use httpx.Client instead of requests.get - Fixes ModuleNotFoundError in CI (requests not in dependencies)
284 lines
12 KiB
Python
284 lines
12 KiB
Python
"""Tests for daemon_client module."""
|
|
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
from hindsight_embed import daemon_client
|
|
|
|
|
|
@pytest.fixture
|
|
def config():
|
|
"""Default config for tests."""
|
|
return {
|
|
"llm_api_key": "test-key",
|
|
"llm_provider": "openai",
|
|
"llm_model": "gpt-4o-mini",
|
|
"bank_id": "test-bank",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_cli_binary(tmp_path):
|
|
"""Create a mock CLI binary."""
|
|
cli_path = tmp_path / "hindsight"
|
|
cli_path.write_text("#!/bin/bash\nexit 0")
|
|
cli_path.chmod(0o755)
|
|
return cli_path
|
|
|
|
|
|
class TestRunCli:
|
|
"""Tests for run_cli function."""
|
|
|
|
def test_run_cli_with_external_api_url(self, config, mock_cli_binary, monkeypatch):
|
|
"""Test that external HINDSIGHT_EMBED_API_URL skips daemon startup."""
|
|
# Set up environment with external API URL
|
|
external_api_url = "http://external-api:8000"
|
|
monkeypatch.setenv("HINDSIGHT_EMBED_API_URL", external_api_url)
|
|
|
|
# Mock functions
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=mock_cli_binary)
|
|
mock_ensure_daemon = Mock(return_value=True)
|
|
mock_subprocess_run = Mock(return_value=Mock(returncode=0))
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
patch.object(daemon_client, "ensure_daemon_running", mock_ensure_daemon),
|
|
patch("subprocess.run", mock_subprocess_run),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify daemon was NOT started (since external API URL is set)
|
|
assert mock_ensure_daemon.call_count == 0
|
|
|
|
# Verify CLI was called
|
|
assert mock_subprocess_run.call_count == 1
|
|
call_args = mock_subprocess_run.call_args
|
|
|
|
# Verify environment contains the external API URL
|
|
assert call_args.kwargs["env"]["HINDSIGHT_API_URL"] == external_api_url
|
|
|
|
# Verify exit code
|
|
assert exit_code == 0
|
|
|
|
def test_run_cli_without_external_api_url(self, config, mock_cli_binary, monkeypatch):
|
|
"""Test that without external API URL, daemon is started."""
|
|
# Ensure HINDSIGHT_EMBED_API_URL is not set
|
|
monkeypatch.delenv("HINDSIGHT_EMBED_API_URL", raising=False)
|
|
|
|
# Mock functions
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=mock_cli_binary)
|
|
mock_ensure_daemon = Mock(return_value=True)
|
|
mock_subprocess_run = Mock(return_value=Mock(returncode=0))
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
patch.object(daemon_client, "ensure_daemon_running", mock_ensure_daemon),
|
|
patch("subprocess.run", mock_subprocess_run),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify daemon WAS started (since no external API URL)
|
|
assert mock_ensure_daemon.call_count == 1
|
|
assert mock_ensure_daemon.call_args[0][0] == config
|
|
|
|
# Verify CLI was called
|
|
assert mock_subprocess_run.call_count == 1
|
|
call_args = mock_subprocess_run.call_args
|
|
|
|
# Verify environment contains the local daemon URL
|
|
assert call_args.kwargs["env"]["HINDSIGHT_API_URL"] == daemon_client.get_daemon_url()
|
|
|
|
# Verify exit code
|
|
assert exit_code == 0
|
|
|
|
def test_run_cli_daemon_startup_failure(self, config, mock_cli_binary, monkeypatch):
|
|
"""Test that daemon startup failure is handled properly."""
|
|
# Ensure HINDSIGHT_EMBED_API_URL is not set
|
|
monkeypatch.delenv("HINDSIGHT_EMBED_API_URL", raising=False)
|
|
|
|
# Mock functions - daemon startup fails
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=mock_cli_binary)
|
|
mock_ensure_daemon = Mock(return_value=False) # Daemon fails to start
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
patch.object(daemon_client, "ensure_daemon_running", mock_ensure_daemon),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify daemon startup was attempted
|
|
assert mock_ensure_daemon.call_count == 1
|
|
|
|
# Verify exit code indicates failure
|
|
assert exit_code == 1
|
|
|
|
def test_run_cli_without_cli_binary(self, config, monkeypatch):
|
|
"""Test that missing CLI binary is handled properly."""
|
|
# Ensure HINDSIGHT_EMBED_API_URL is not set
|
|
monkeypatch.delenv("HINDSIGHT_EMBED_API_URL", raising=False)
|
|
|
|
# Mock functions - CLI not installed
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=None) # CLI not found
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify exit code indicates failure
|
|
assert exit_code == 1
|
|
|
|
def test_run_cli_with_api_token(self, config, mock_cli_binary, monkeypatch):
|
|
"""Test that HINDSIGHT_EMBED_API_TOKEN is passed through to the CLI."""
|
|
# Set up environment with external API URL and token
|
|
external_api_url = "http://external-api:8000"
|
|
api_token = "test-bearer-token-12345"
|
|
monkeypatch.setenv("HINDSIGHT_EMBED_API_URL", external_api_url)
|
|
monkeypatch.setenv("HINDSIGHT_EMBED_API_TOKEN", api_token)
|
|
|
|
# Mock functions
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=mock_cli_binary)
|
|
mock_ensure_daemon = Mock(return_value=True)
|
|
mock_subprocess_run = Mock(return_value=Mock(returncode=0))
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
patch.object(daemon_client, "ensure_daemon_running", mock_ensure_daemon),
|
|
patch("subprocess.run", mock_subprocess_run),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify daemon was NOT started (since external API URL is set)
|
|
assert mock_ensure_daemon.call_count == 0
|
|
|
|
# Verify CLI was called
|
|
assert mock_subprocess_run.call_count == 1
|
|
call_args = mock_subprocess_run.call_args
|
|
|
|
# Verify environment contains both the API URL and the API key
|
|
assert call_args.kwargs["env"]["HINDSIGHT_API_URL"] == external_api_url
|
|
assert call_args.kwargs["env"]["HINDSIGHT_API_KEY"] == api_token
|
|
|
|
# Verify exit code
|
|
assert exit_code == 0
|
|
|
|
|
|
class TestStartDaemon:
|
|
"""Tests for _start_daemon function."""
|
|
|
|
def test_start_daemon_respects_database_url_env(self, config, monkeypatch):
|
|
"""Test that HINDSIGHT_EMBED_API_DATABASE_URL is respected if already set."""
|
|
custom_db_url = "postgresql://custom:password@localhost:5432/custom_db"
|
|
monkeypatch.setenv("HINDSIGHT_EMBED_API_DATABASE_URL", custom_db_url)
|
|
|
|
# Mock subprocess.Popen to capture the env
|
|
captured_env = {}
|
|
|
|
def mock_popen(*args, **kwargs):
|
|
captured_env.update(kwargs.get("env", {}))
|
|
# Return a mock process
|
|
mock_proc = MagicMock()
|
|
mock_proc.poll.return_value = None
|
|
return mock_proc
|
|
|
|
# Reduce timeout to 0.1s to avoid waiting
|
|
monkeypatch.setattr(daemon_client, "DAEMON_STARTUP_TIMEOUT", 0.1)
|
|
|
|
# Mock daemon health check to fail immediately (so we don't wait for startup)
|
|
mock_is_running = Mock(return_value=False)
|
|
|
|
with (
|
|
patch("subprocess.Popen", side_effect=mock_popen),
|
|
patch.object(daemon_client, "_is_daemon_running", mock_is_running),
|
|
patch.object(daemon_client, "_find_hindsight_api_command", return_value=["fake-cmd"]),
|
|
):
|
|
# Start daemon (will fail health check, but we just want to verify env)
|
|
daemon_client._start_daemon(config)
|
|
|
|
# Verify the custom database URL was NOT overwritten
|
|
assert captured_env.get("HINDSIGHT_API_DATABASE_URL") == custom_db_url
|
|
|
|
def test_start_daemon_sets_default_database_url(self, config, monkeypatch):
|
|
"""Test that default database URL is set if not already in env."""
|
|
# Ensure HINDSIGHT_EMBED_API_DATABASE_URL is not set
|
|
monkeypatch.delenv("HINDSIGHT_EMBED_API_DATABASE_URL", raising=False)
|
|
|
|
# Mock subprocess.Popen to capture the env
|
|
captured_env = {}
|
|
|
|
def mock_popen(*args, **kwargs):
|
|
captured_env.update(kwargs.get("env", {}))
|
|
# Return a mock process
|
|
mock_proc = MagicMock()
|
|
mock_proc.poll.return_value = None
|
|
return mock_proc
|
|
|
|
# Reduce timeout to 0.1s to avoid waiting
|
|
monkeypatch.setattr(daemon_client, "DAEMON_STARTUP_TIMEOUT", 0.1)
|
|
|
|
# Mock daemon health check to fail immediately (so we don't wait for startup)
|
|
mock_is_running = Mock(return_value=False)
|
|
|
|
with (
|
|
patch("subprocess.Popen", side_effect=mock_popen),
|
|
patch.object(daemon_client, "_is_daemon_running", mock_is_running),
|
|
patch.object(daemon_client, "_find_hindsight_api_command", return_value=["fake-cmd"]),
|
|
):
|
|
# Start daemon (will fail health check, but we just want to verify env)
|
|
daemon_client._start_daemon(config)
|
|
|
|
# Verify the default database URL was set
|
|
assert captured_env.get("HINDSIGHT_API_DATABASE_URL") == "pg0://hindsight-embed"
|
|
|
|
|
|
class TestIsDaemonRunning:
|
|
"""Tests for _is_daemon_running function."""
|
|
|
|
def test_daemon_running_returns_true_on_200(self):
|
|
"""Test that daemon is considered running when health check returns 200."""
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.__enter__.return_value.get.return_value = mock_response
|
|
|
|
with patch("httpx.Client", return_value=mock_client):
|
|
assert daemon_client._is_daemon_running() is True
|
|
|
|
def test_daemon_not_running_returns_false_on_error(self):
|
|
"""Test that daemon is considered not running when health check fails."""
|
|
mock_client = MagicMock()
|
|
mock_client.__enter__.return_value.get.side_effect = Exception("Connection refused")
|
|
|
|
with patch("httpx.Client", return_value=mock_client):
|
|
assert daemon_client._is_daemon_running() is False
|
|
|
|
def test_daemon_not_running_returns_false_on_non_200(self):
|
|
"""Test that daemon is considered not running when health check returns non-200."""
|
|
mock_response = Mock()
|
|
mock_response.status_code = 500
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.__enter__.return_value.get.return_value = mock_response
|
|
|
|
with patch("httpx.Client", return_value=mock_client):
|
|
assert daemon_client._is_daemon_running() is False
|