diff --git a/src/aipass/api/apps/api.py b/src/aipass/api/apps/api.py index 849d65d6..359af8d5 100644 --- a/src/aipass/api/apps/api.py +++ b/src/aipass/api/apps/api.py @@ -257,7 +257,7 @@ def main(): error("No modules found") return 1 - # Extract command and remaining args (matching seed pattern) + # Extract command and remaining args (matching seedgo pattern) command = args[0] remaining_args = args[1:] if len(args) > 1 else [] diff --git a/src/aipass/api/apps/handlers/__init__.py b/src/aipass/api/apps/handlers/__init__.py index a09d46ca..e3486505 100644 --- a/src/aipass/api/apps/handlers/__init__.py +++ b/src/aipass/api/apps/handlers/__init__.py @@ -28,7 +28,7 @@ def _extract_branch_name(filepath: str) -> str: """Extract branch name from a file path.""" parts = Path(filepath).parts for i, part in enumerate(parts): - if part in ("MEMORY_BANK", "seed", ".vscode"): + if part in ("memory", "seedgo", ".vscode"): if i + 1 < len(parts): return parts[i + 1] if part in ("aipass",) and i + 1 < len(parts) and parts[i + 1] == "apps": diff --git a/src/aipass/api/apps/handlers/json/__init__.py b/src/aipass/api/apps/handlers/json/__init__.py index f44e47b3..287ee1a5 100644 --- a/src/aipass/api/apps/handlers/json/__init__.py +++ b/src/aipass/api/apps/handlers/json/__init__.py @@ -1 +1 @@ -"""JSON Handlers - Universal JSON operations for Seed branch""" +"""JSON Handlers - Universal JSON operations for API branch""" diff --git a/src/aipass/api/tests/conftest.py b/src/aipass/api/tests/conftest.py index 3cccc54b..0a99cafb 100644 --- a/src/aipass/api/tests/conftest.py +++ b/src/aipass/api/tests/conftest.py @@ -4,7 +4,7 @@ # Name: tests/conftest.py # Date: 2025-11-08 # Version: 1.0.0 -# Category: cortex/tests +# Category: api/tests # # CHANGELOG (Max 5 entries): # - v1.0.0 (2025-11-08): Initial implementation - Shared pytest fixtures @@ -13,7 +13,7 @@ # - Error handling: Use error handler system (apps/handlers/error/) # ============================================= -"""Shared pytest fixtures for cortex tests""" +"""Shared pytest fixtures for api tests""" import pytest import shutil import tempfile diff --git a/src/aipass/api/tests/test_api_key.py b/src/aipass/api/tests/test_api_key.py new file mode 100644 index 00000000..6c69a4b2 --- /dev/null +++ b/src/aipass/api/tests/test_api_key.py @@ -0,0 +1,495 @@ +# =================== AIPass ==================== +# Name: test_api_key.py +# Description: Tests for API Key Management Module +# Version: 1.0.0 +# Created: 2026-03-24 +# Modified: 2026-03-24 +# ============================================= + +""" +Tests for api_key.py — API key management module. + +Tests: +- handle_command routing for all known commands +- handle_command returns False for unknown commands +- Help gate triggers print_help +- Introspection gate triggers print_introspection for no-args +- get_key success/failure paths +- validate_key valid/invalid/no-key paths +- init_env existing/create paths +- list_providers workflow +- json_handler.log_operation called on valid commands +""" + +from unittest.mock import patch, MagicMock, call +from pathlib import Path + +import pytest + +from aipass.api.apps.modules import api_key + + +# ============================================= +# Shared patch decorator — suppresses all CLI output +# ============================================= + +PATCH_CONSOLE = "aipass.api.apps.modules.api_key.console" +PATCH_HEADER = "aipass.api.apps.modules.api_key.header" +PATCH_SUCCESS = "aipass.api.apps.modules.api_key.success" +PATCH_ERROR = "aipass.api.apps.modules.api_key.error" +PATCH_WARNING = "aipass.api.apps.modules.api_key.warning" +PATCH_JSON_HANDLER = "aipass.api.apps.modules.api_key.json_handler" +PATCH_KEYS = "aipass.api.apps.modules.api_key.keys" +PATCH_ENV = "aipass.api.apps.modules.api_key.env" +PATCH_PROVIDER = "aipass.api.apps.modules.api_key.provider" + + +# ============================================= +# handle_command — routing tests +# ============================================= + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_JSON_HANDLER) +def test_handle_command_returns_false_for_unknown_command(mock_jh, mock_header, mock_console): + """Unknown command should return False without logging.""" + result = api_key.handle_command("unknown", []) + + assert result is False + mock_jh.log_operation.assert_not_called() + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_SUCCESS) +@patch(PATCH_ERROR) +@patch(PATCH_JSON_HANDLER) +@patch(PATCH_KEYS) +def test_handle_command_routes_get_key(mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console): + """get-key command should route to get_key with args.""" + mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" + + result = api_key.handle_command("get-key", ["openrouter"]) + + assert result is True + mock_keys.get_api_key.assert_called_once_with("openrouter") + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_SUCCESS) +@patch(PATCH_ERROR) +@patch(PATCH_JSON_HANDLER) +@patch(PATCH_KEYS) +def test_handle_command_routes_validate(mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console): + """validate command should route to validate_key with args.""" + mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" + mock_keys.validate_key.return_value = True + + result = api_key.handle_command("validate", ["openrouter"]) + + assert result is True + mock_keys.get_api_key.assert_called_once_with("openrouter") + mock_keys.validate_key.assert_called_once_with("sk-test1234567890abcdef", "openrouter") + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_JSON_HANDLER) +def test_handle_command_routes_list_providers(mock_jh, mock_header, mock_console): + """list-providers command should route to list_providers.""" + result = api_key.handle_command("list-providers", []) + + assert result is True + mock_header.assert_called_once_with("Available Providers") + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_SUCCESS) +@patch(PATCH_ERROR) +@patch(PATCH_JSON_HANDLER) +@patch(PATCH_ENV) +def test_handle_command_routes_init(mock_env, mock_jh, mock_error, mock_success, mock_header, mock_console): + """init command should route to init_env.""" + with patch("aipass.api.apps.modules.api_key.Path") as mock_path_cls: + mock_env_path = MagicMock() + mock_env_path.exists.return_value = True + mock_path_cls.home.return_value.__truediv__ = MagicMock(return_value=mock_env_path) + # Simpler: just mock the whole Path.home() chain + mock_path_cls.home.return_value = MagicMock() + mock_path_cls.home.return_value.__truediv__ = MagicMock() + mock_home = MagicMock() + mock_secrets = MagicMock() + mock_aipass = MagicMock() + mock_env_path = MagicMock() + mock_env_path.exists.return_value = True + mock_home.__truediv__ = MagicMock(return_value=mock_secrets) + mock_secrets.__truediv__ = MagicMock(return_value=mock_aipass) + mock_aipass.__truediv__ = MagicMock(return_value=mock_env_path) + mock_path_cls.home.return_value = mock_home + + result = api_key.handle_command("init", []) + + assert result is True + + +# ============================================= +# handle_command — gate tests +# ============================================= + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_JSON_HANDLER) +def test_handle_command_help_gate(mock_jh, mock_header, mock_console): + """--help arg should trigger print_help and return True without logging.""" + result = api_key.handle_command("get-key", ["--help"]) + + assert result is True + # Help gate fires before log_operation + mock_jh.log_operation.assert_not_called() + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_JSON_HANDLER) +def test_handle_command_help_gate_short_flag(mock_jh, mock_header, mock_console): + """Short -h flag should also trigger help gate.""" + result = api_key.handle_command("validate", ["-h"]) + + assert result is True + mock_jh.log_operation.assert_not_called() + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_JSON_HANDLER) +def test_handle_command_introspection_gate(mock_jh, mock_header, mock_console): + """get-key with no args should trigger print_introspection and return True.""" + result = api_key.handle_command("get-key", []) + + assert result is True + # Introspection gate fires after log_operation + mock_jh.log_operation.assert_called_once() + # Introspection prints the header "API Key Module Introspection" + mock_header.assert_called_with("API Key Module Introspection") + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_JSON_HANDLER) +def test_handle_command_list_providers_standalone(mock_jh, mock_header, mock_console): + """list-providers should work without args and not hit introspection gate.""" + result = api_key.handle_command("list-providers", []) + + assert result is True + # Should call "Available Providers" header, not introspection + mock_header.assert_called_with("Available Providers") + + +# ============================================= +# get_key tests +# ============================================= + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_SUCCESS) +@patch(PATCH_ERROR) +@patch(PATCH_KEYS) +def test_get_key_success(mock_keys, mock_error, mock_success, mock_header, mock_console): + """Successful key retrieval should call success().""" + mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" + + api_key.get_key(["openrouter"]) + + mock_keys.get_api_key.assert_called_once_with("openrouter") + mock_success.assert_called_once_with("API key retrieved for openrouter") + mock_error.assert_not_called() + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_SUCCESS) +@patch(PATCH_ERROR) +@patch(PATCH_KEYS) +def test_get_key_failure(mock_keys, mock_error, mock_success, mock_header, mock_console): + """Failed key retrieval (None returned) should call error().""" + mock_keys.get_api_key.return_value = None + + api_key.get_key(["openrouter"]) + + mock_keys.get_api_key.assert_called_once_with("openrouter") + mock_error.assert_called_once_with("Failed to retrieve API key for openrouter") + mock_success.assert_not_called() + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_SUCCESS) +@patch(PATCH_ERROR) +@patch(PATCH_KEYS) +def test_get_key_default_provider(mock_keys, mock_error, mock_success, mock_header, mock_console): + """Empty args list should default to 'openrouter' provider.""" + mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" + + api_key.get_key([]) + + mock_keys.get_api_key.assert_called_once_with("openrouter") + + +# ============================================= +# validate_key tests +# ============================================= + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_SUCCESS) +@patch(PATCH_ERROR) +@patch(PATCH_KEYS) +def test_validate_key_valid(mock_keys, mock_error, mock_success, mock_header, mock_console): + """Valid key should call success().""" + mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" + mock_keys.validate_key.return_value = True + + api_key.validate_key(["openrouter"]) + + mock_keys.get_api_key.assert_called_once_with("openrouter") + mock_keys.validate_key.assert_called_once_with("sk-test1234567890abcdef", "openrouter") + mock_success.assert_called_once_with("API key for openrouter is valid") + mock_error.assert_not_called() + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_SUCCESS) +@patch(PATCH_ERROR) +@patch(PATCH_KEYS) +def test_validate_key_no_key(mock_keys, mock_error, mock_success, mock_header, mock_console): + """No key found should call error() and skip validation.""" + mock_keys.get_api_key.return_value = None + + api_key.validate_key(["openrouter"]) + + mock_keys.get_api_key.assert_called_once_with("openrouter") + mock_keys.validate_key.assert_not_called() + mock_error.assert_called_once_with("No API key found for openrouter") + mock_success.assert_not_called() + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_SUCCESS) +@patch(PATCH_ERROR) +@patch(PATCH_KEYS) +def test_validate_key_invalid(mock_keys, mock_error, mock_success, mock_header, mock_console): + """Invalid key should call error().""" + mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" + mock_keys.validate_key.return_value = False + + api_key.validate_key(["openrouter"]) + + mock_keys.get_api_key.assert_called_once_with("openrouter") + mock_keys.validate_key.assert_called_once_with("sk-test1234567890abcdef", "openrouter") + mock_error.assert_called_once_with("API key for openrouter is invalid") + mock_success.assert_not_called() + + +# ============================================= +# init_env tests +# ============================================= + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_SUCCESS) +@patch(PATCH_ERROR) +@patch(PATCH_ENV) +def test_init_env_already_exists(mock_env, mock_error, mock_success, mock_header, mock_console): + """Existing env file should call success() and skip creation.""" + with patch("aipass.api.apps.modules.api_key.Path") as mock_path_cls: + mock_env_path = MagicMock() + mock_env_path.exists.return_value = True + + mock_home = MagicMock() + mock_secrets = MagicMock() + mock_aipass_dir = MagicMock() + mock_home.__truediv__ = MagicMock(return_value=mock_secrets) + mock_secrets.__truediv__ = MagicMock(return_value=mock_aipass_dir) + mock_aipass_dir.__truediv__ = MagicMock(return_value=mock_env_path) + mock_path_cls.home.return_value = mock_home + + api_key.init_env() + + mock_success.assert_called_once() + assert "already exists" in mock_success.call_args[0][0] + mock_env.create_env_template.assert_not_called() + mock_error.assert_not_called() + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_SUCCESS) +@patch(PATCH_ERROR) +@patch(PATCH_ENV) +def test_init_env_creates_template(mock_env, mock_error, mock_success, mock_header, mock_console): + """Missing env file should call create_env_template and success() on True.""" + mock_env.create_env_template.return_value = True + + with patch("aipass.api.apps.modules.api_key.Path") as mock_path_cls: + mock_env_path = MagicMock() + mock_env_path.exists.return_value = False + + mock_home = MagicMock() + mock_secrets = MagicMock() + mock_aipass_dir = MagicMock() + mock_home.__truediv__ = MagicMock(return_value=mock_secrets) + mock_secrets.__truediv__ = MagicMock(return_value=mock_aipass_dir) + mock_aipass_dir.__truediv__ = MagicMock(return_value=mock_env_path) + mock_path_cls.home.return_value = mock_home + + api_key.init_env() + + mock_env.create_env_template.assert_called_once() + mock_success.assert_called_once() + assert "template created" in mock_success.call_args[0][0] + mock_error.assert_not_called() + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_SUCCESS) +@patch(PATCH_ERROR) +@patch(PATCH_ENV) +def test_init_env_create_failure(mock_env, mock_error, mock_success, mock_header, mock_console): + """Failed template creation should call error().""" + mock_env.create_env_template.return_value = False + + with patch("aipass.api.apps.modules.api_key.Path") as mock_path_cls: + mock_env_path = MagicMock() + mock_env_path.exists.return_value = False + + mock_home = MagicMock() + mock_secrets = MagicMock() + mock_aipass_dir = MagicMock() + mock_home.__truediv__ = MagicMock(return_value=mock_secrets) + mock_secrets.__truediv__ = MagicMock(return_value=mock_aipass_dir) + mock_aipass_dir.__truediv__ = MagicMock(return_value=mock_env_path) + mock_path_cls.home.return_value = mock_home + + api_key.init_env() + + mock_env.create_env_template.assert_called_once() + mock_error.assert_called_once_with("Failed to create environment template") + mock_success.assert_not_called() + + +# ============================================= +# list_providers tests +# ============================================= + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +def test_list_providers_prints_header(mock_header, mock_console): + """list_providers should print 'Available Providers' header.""" + api_key.list_providers() + + mock_header.assert_called_once_with("Available Providers") + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +def test_list_providers_prints_openrouter(mock_header, mock_console): + """list_providers should print openrouter as an available provider.""" + api_key.list_providers() + + # Check that console.print was called with the openrouter provider line + print_calls = [str(c) for c in mock_console.print.call_args_list] + found = any("openrouter" in c for c in print_calls) + assert found, f"Expected 'openrouter' in console output, got: {print_calls}" + + +# ============================================= +# log_operation tests +# ============================================= + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_JSON_HANDLER) +def test_handle_command_logs_operation(mock_jh, mock_header, mock_console): + """Valid command should call json_handler.log_operation with command context.""" + api_key.handle_command("list-providers", []) + + mock_jh.log_operation.assert_called_once_with( + "api_key_list-providers", + {"command": "list-providers"}, + ) + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +@patch(PATCH_SUCCESS) +@patch(PATCH_ERROR) +@patch(PATCH_JSON_HANDLER) +@patch(PATCH_KEYS) +def test_handle_command_logs_operation_for_get_key(mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console): + """get-key command should log api_key_get-key operation.""" + mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" + + api_key.handle_command("get-key", ["openrouter"]) + + mock_jh.log_operation.assert_called_once_with( + "api_key_get-key", + {"command": "get-key"}, + ) + + +# ============================================= +# print_introspection tests +# ============================================= + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +def test_print_introspection_shows_header(mock_header, mock_console): + """print_introspection should display the module header.""" + api_key.print_introspection() + + mock_header.assert_called_once_with("API Key Module Introspection") + + +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +def test_print_introspection_shows_handlers(mock_header, mock_console): + """print_introspection should list connected handlers.""" + api_key.print_introspection() + + print_calls = [str(c) for c in mock_console.print.call_args_list] + found_keys = any("auth.keys" in c for c in print_calls) + found_env = any("auth.env" in c for c in print_calls) + found_provider = any("config.provider" in c for c in print_calls) + assert found_keys, "Expected auth.keys handler listed" + assert found_env, "Expected auth.env handler listed" + assert found_provider, "Expected config.provider handler listed" + + +# ============================================= +# handle_command — exception propagation +# ============================================= + + +@patch(PATCH_KEYS) +@patch(PATCH_JSON_HANDLER) +@patch(PATCH_CONSOLE) +@patch(PATCH_HEADER) +def test_handle_command_propagates_exception(mock_header, mock_console, mock_jh, mock_keys): + """handle_command re-raises exceptions from downstream handlers.""" + mock_keys.get_api_key.side_effect = RuntimeError("handler failed") + + with pytest.raises(RuntimeError, match="handler failed"): + api_key.handle_command("get-key", ["openrouter"]) diff --git a/src/aipass/api/tests/test_google_client.py b/src/aipass/api/tests/test_google_client.py new file mode 100644 index 00000000..2ed4256e --- /dev/null +++ b/src/aipass/api/tests/test_google_client.py @@ -0,0 +1,795 @@ +# =================== AIPass ==================== +# Name: test_google_client.py +# Description: Tests for Google API client module +# Version: 1.0.0 +# Created: 2026-03-24 +# Modified: 2026-03-24 +# ============================================= + +""" +Tests for google_client.py — Google API Client Module. + +Tests: +- handle_command routing: help, introspection, validate, reauth, pass-through +- get_drive_service() delegates to get_google_service() +- get_google_service() standard and thread-safe paths +- get_google_service() error paths: libs missing, auth failure +- validate_google() success and failure +- authenticate_google() success and failure +- reauth_google() success and failure +- api_call_with_retry() delegation +- is_ssl_error() delegation +""" + +from unittest.mock import patch, MagicMock, call + +import pytest + +from aipass.api.apps.modules.google_client import handle_command as _hc # noqa: F401 — seedgo test_coverage detection + + +# Base patch path for all mocks in this module +_MOD = "aipass.api.apps.modules.google_client" + + +# ============================================= +# handle_command tests +# ============================================= + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_handle_command_returns_false_no_args( + _warn, _err, _succ, _hdr, _json, _retry, _factory, _auth, _console +): + """handle_command returns False when args=[] and command != 'google'.""" + from aipass.api.apps.modules import google_client + + result = google_client.handle_command("validate", []) + assert result is False + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_handle_command_returns_false_non_google_provider( + _warn, _err, _succ, _hdr, _json, _retry, _factory, _auth, _console +): + """handle_command returns False when provider is not 'google'.""" + from aipass.api.apps.modules import google_client + + result = google_client.handle_command("validate", ["openrouter"]) + assert result is False + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_handle_command_routes_validate_google( + _warn, _err, _succ, _hdr, mock_json, _retry, _factory, mock_auth, _console +): + """handle_command routes 'validate' with ['google'] to _cmd_validate.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = True + mock_auth.CLIENT_SECRET_PATH.exists.return_value = True + mock_auth.validate_credentials.return_value = True + + result = google_client.handle_command("validate", ["google"]) + + assert result is True + mock_auth.validate_credentials.assert_called_once() + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_handle_command_routes_reauth_google( + mock_warn, _err, _succ, _hdr, mock_json, _retry, _factory, mock_auth, _console +): + """handle_command routes 'reauth' with ['google'] to _cmd_reauth.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = True + mock_auth.CLIENT_SECRET_PATH.exists.return_value = True + mock_auth.reauth.return_value = MagicMock() + + result = google_client.handle_command("reauth", ["google"]) + + assert result is True + mock_auth.reauth.assert_called_once() + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_handle_command_help_gate( + _warn, _err, _succ, _hdr, _json, _retry, _factory, _auth, mock_console +): + """handle_command prints help when args=['--help'] and returns True.""" + from aipass.api.apps.modules import google_client + + result = google_client.handle_command("validate", ["--help"]) + + assert result is True + # print_help calls console.print with the argparse output + mock_console.print.assert_called() + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_handle_command_google_introspection( + _warn, _err, _succ, mock_hdr, _json, _retry, _factory, mock_auth, mock_console +): + """handle_command prints introspection when command='google' and args=[].""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = True + mock_auth.CREDS_PATH.exists.return_value = True + mock_auth.CLIENT_SECRET_PATH.exists.return_value = True + + result = google_client.handle_command("google", []) + + assert result is True + mock_hdr.assert_called_once_with("Google Client Module Introspection") + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_handle_command_unknown_command_returns_false( + _warn, _err, _succ, _hdr, _json, _retry, _factory, _auth, _console +): + """handle_command returns False for unknown commands with 'google' arg.""" + from aipass.api.apps.modules import google_client + + result = google_client.handle_command("deploy", ["google"]) + assert result is False + + +# ============================================= +# get_drive_service tests +# ============================================= + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_get_drive_service_delegates( + _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console +): + """get_drive_service() delegates to get_google_service('drive', 'v3').""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = True + mock_service = MagicMock() + mock_factory.build_service.return_value = mock_service + + result = google_client.get_drive_service() + + mock_factory.build_service.assert_called_once_with("drive", "v3", None) + assert result is mock_service + + +# ============================================= +# get_google_service tests +# ============================================= + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_get_google_service_standard( + _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console +): + """get_google_service() returns service via build_service when is_available=True.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = True + mock_service = MagicMock() + mock_factory.build_service.return_value = mock_service + + result = google_client.get_google_service("calendar", "v3") + + mock_factory.build_service.assert_called_once_with("calendar", "v3", None) + assert result is mock_service + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_get_google_service_thread_safe( + _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console +): + """get_google_service(thread_safe=True) calls build_thread_safe_service.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = True + mock_service = MagicMock() + mock_factory.build_thread_safe_service.return_value = mock_service + + result = google_client.get_google_service("drive", "v3", thread_safe=True) + + mock_factory.build_thread_safe_service.assert_called_once_with("drive", "v3", None) + assert result is mock_service + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_get_google_service_libs_not_available( + _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console +): + """get_google_service() raises RuntimeError when libraries are not installed.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = False + + with pytest.raises(RuntimeError, match="not installed"): + google_client.get_google_service() + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_get_google_service_auth_failure( + _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console +): + """get_google_service() raises RuntimeError when build_service returns None.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = True + mock_factory.build_service.return_value = None + + with pytest.raises(RuntimeError, match="Failed to authenticate"): + google_client.get_google_service("drive", "v3") + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_get_google_service_with_custom_scopes( + _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console +): + """get_google_service() passes custom scopes through to build_service.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = True + mock_service = MagicMock() + mock_factory.build_service.return_value = mock_service + scopes = ["https://www.googleapis.com/auth/drive.readonly"] + + result = google_client.get_google_service("drive", "v3", scopes=scopes) + + mock_factory.build_service.assert_called_once_with("drive", "v3", scopes) + assert result is mock_service + + +# ============================================= +# validate_google tests +# ============================================= + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_validate_google_true( + _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console +): + """validate_google() returns True when credentials are valid.""" + from aipass.api.apps.modules import google_client + + mock_auth.validate_credentials.return_value = True + + result = google_client.validate_google() + + assert result is True + mock_auth.validate_credentials.assert_called_once_with(scopes=None) + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_validate_google_false( + _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console +): + """validate_google() returns False when no valid credentials exist.""" + from aipass.api.apps.modules import google_client + + mock_auth.validate_credentials.return_value = False + + result = google_client.validate_google() + + assert result is False + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_validate_google_with_scopes( + _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console +): + """validate_google() passes scopes to validate_credentials.""" + from aipass.api.apps.modules import google_client + + mock_auth.validate_credentials.return_value = True + scopes = ["https://www.googleapis.com/auth/calendar"] + + result = google_client.validate_google(scopes=scopes) + + assert result is True + mock_auth.validate_credentials.assert_called_once_with(scopes=scopes) + + +# ============================================= +# authenticate_google tests +# ============================================= + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_authenticate_google_success( + _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console +): + """authenticate_google() returns True when authenticate returns credentials.""" + from aipass.api.apps.modules import google_client + + mock_auth.authenticate.return_value = MagicMock() + + result = google_client.authenticate_google() + + assert result is True + mock_auth.authenticate.assert_called_once_with(scopes=None) + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_authenticate_google_failure( + _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console +): + """authenticate_google() returns False when authenticate returns None.""" + from aipass.api.apps.modules import google_client + + mock_auth.authenticate.return_value = None + + result = google_client.authenticate_google() + + assert result is False + + +# ============================================= +# reauth_google tests +# ============================================= + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_reauth_google_success( + _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console +): + """reauth_google() returns True when reauth returns credentials.""" + from aipass.api.apps.modules import google_client + + mock_auth.reauth.return_value = MagicMock() + + result = google_client.reauth_google() + + assert result is True + mock_auth.reauth.assert_called_once_with(scopes=None) + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_reauth_google_failure( + _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console +): + """reauth_google() returns False when reauth returns None.""" + from aipass.api.apps.modules import google_client + + mock_auth.reauth.return_value = None + + result = google_client.reauth_google() + + assert result is False + + +# ============================================= +# api_call_with_retry tests +# ============================================= + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_api_call_with_retry_delegates( + _warn, _err, _succ, _hdr, _json, mock_retry, _factory, _auth, _console +): + """api_call_with_retry() delegates to google_retry.api_call_with_retry.""" + from aipass.api.apps.modules import google_client + + mock_callable = MagicMock() + mock_retry.api_call_with_retry.return_value = "result" + + result = google_client.api_call_with_retry(mock_callable, retries=3) + + mock_retry.api_call_with_retry.assert_called_once_with(mock_callable, retries=3) + assert result == "result" + + +# ============================================= +# is_ssl_error tests +# ============================================= + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_is_ssl_error_delegates( + _warn, _err, _succ, _hdr, _json, mock_retry, _factory, _auth, _console +): + """is_ssl_error() delegates to google_retry.is_ssl_error.""" + from aipass.api.apps.modules import google_client + + test_error = Exception("SSL handshake failed") + mock_retry.is_ssl_error.return_value = True + + result = google_client.is_ssl_error(test_error) + + mock_retry.is_ssl_error.assert_called_once_with(test_error) + assert result is True + + +# ============================================= +# _cmd_validate CLI integration tests +# ============================================= + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_cmd_validate_libs_not_available( + _warn, mock_err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console +): + """_cmd_validate shows error when Google libs are not installed.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = False + + google_client._cmd_validate() + + mock_err.assert_called_once() + assert "not installed" in mock_err.call_args[0][0] + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_cmd_validate_no_client_secret( + _warn, mock_err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console +): + """_cmd_validate shows error when client secret file is missing.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = True + mock_auth.CLIENT_SECRET_PATH.exists.return_value = False + + google_client._cmd_validate() + + mock_err.assert_called_once() + assert "Client secret not found" in mock_err.call_args[0][0] + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_cmd_validate_valid_creds( + _warn, _err, mock_succ, _hdr, mock_json, _retry, _factory, mock_auth, _console +): + """_cmd_validate shows success when credentials are valid.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = True + mock_auth.CLIENT_SECRET_PATH.exists.return_value = True + mock_auth.validate_credentials.return_value = True + + google_client._cmd_validate() + + mock_succ.assert_called_once_with("Google credentials are valid") + mock_json.log_operation.assert_called_once_with( + "google_validate", {"status": "valid"} + ) + _err.assert_not_called() + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_cmd_validate_invalid_creds( + mock_warn, _err, _succ, _hdr, mock_json, _retry, _factory, mock_auth, mock_console +): + """_cmd_validate shows warning when credentials are invalid.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = True + mock_auth.CLIENT_SECRET_PATH.exists.return_value = True + mock_auth.validate_credentials.return_value = False + + google_client._cmd_validate() + + mock_warn.assert_called_once_with("No valid Google credentials found") + mock_json.log_operation.assert_called_once_with( + "google_validate", {"status": "invalid"} + ) + _succ.assert_not_called() + + +# ============================================= +# _cmd_reauth CLI integration tests +# ============================================= + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_cmd_reauth_libs_not_available( + _warn, mock_err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console +): + """_cmd_reauth shows error when Google libs are not installed.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = False + + google_client._cmd_reauth() + + mock_err.assert_called_once() + assert "not installed" in mock_err.call_args[0][0] + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_cmd_reauth_success( + _warn, _err, mock_succ, _hdr, mock_json, _retry, _factory, mock_auth, _console +): + """_cmd_reauth shows success when reauth returns credentials.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = True + mock_auth.CLIENT_SECRET_PATH.exists.return_value = True + mock_auth.reauth.return_value = MagicMock() + + google_client._cmd_reauth() + + mock_succ.assert_called_once_with("Google re-authentication successful") + mock_json.log_operation.assert_called_once_with( + "google_reauth", {"status": "success"} + ) + _err.assert_not_called() + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_cmd_reauth_failure( + _warn, mock_err, _succ, _hdr, mock_json, _retry, _factory, mock_auth, _console +): + """_cmd_reauth shows error when reauth returns None.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.return_value = True + mock_auth.CLIENT_SECRET_PATH.exists.return_value = True + mock_auth.reauth.return_value = None + + google_client._cmd_reauth() + + mock_err.assert_called_once_with("Google re-authentication failed") + mock_json.log_operation.assert_called_once_with( + "google_reauth", {"status": "failed"} + ) + _succ.assert_not_called() + + +# ============================================= +# handle_command — exception propagation +# ============================================= + + +@patch(f"{_MOD}.console") +@patch(f"{_MOD}.google_auth") +@patch(f"{_MOD}.google_factory") +@patch(f"{_MOD}.google_retry") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.warning") +def test_handle_command_propagates_exception( + _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console +): + """handle_command re-raises exceptions from downstream handlers.""" + from aipass.api.apps.modules import google_client + + mock_auth.is_available.side_effect = RuntimeError("handler failed") + + with pytest.raises(RuntimeError, match="handler failed"): + google_client.handle_command("validate", ["google"]) diff --git a/src/aipass/api/tests/test_openrouter_client.py b/src/aipass/api/tests/test_openrouter_client.py new file mode 100644 index 00000000..d8832549 --- /dev/null +++ b/src/aipass/api/tests/test_openrouter_client.py @@ -0,0 +1,651 @@ +# =================== AIPass ==================== +# Name: test_openrouter_client.py +# Description: Tests for OpenRouter client module +# Version: 1.0.0 +# Created: 2026-03-24 +# Modified: 2026-03-24 +# ============================================= + +""" +Tests for openrouter_client.py — OpenRouter client module orchestration. + +Tests: +- handle_command routing for test, call, models, status, unknown +- Help gate (--help) and introspection gate (no-args on "call") +- log_operation called on every valid command +- test_connection success / no-key / API-failure paths +- list_models success / no-key / --all limiter +- check_status with key / without key +- get_response delegation to client handler +""" + +from unittest.mock import patch, MagicMock, call + +import pytest + +from aipass.api.apps.modules.openrouter_client import handle_command as _hc # noqa: F401 — seedgo test_coverage detection + + +# Base set of patches applied to every test via the module-level prefix +_MOD = "aipass.api.apps.modules.openrouter_client" + + +# ============================================= +# handle_command — routing +# ============================================= + + +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_handle_command_returns_false_for_unknown(mock_console, mock_header, mock_jh): + """handle_command returns False when the command is not recognised.""" + from aipass.api.apps.modules import openrouter_client + + result = openrouter_client.handle_command("unknown", []) + + assert result is False + mock_jh.log_operation.assert_not_called() + + +@patch(f"{_MOD}.test_connection") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_handle_command_routes_test(mock_console, mock_header, mock_jh, mock_test): + """handle_command('test', []) delegates to test_connection().""" + from aipass.api.apps.modules import openrouter_client + + result = openrouter_client.handle_command("test", []) + + assert result is True + mock_test.assert_called_once() + + +@patch(f"{_MOD}.list_models") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_handle_command_routes_models(mock_console, mock_header, mock_jh, mock_list): + """handle_command('models', []) delegates to list_models().""" + from aipass.api.apps.modules import openrouter_client + + result = openrouter_client.handle_command("models", []) + + assert result is True + mock_list.assert_called_once_with([]) + + +@patch(f"{_MOD}.check_status") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_handle_command_routes_status(mock_console, mock_header, mock_jh, mock_status): + """handle_command('status', []) delegates to check_status().""" + from aipass.api.apps.modules import openrouter_client + + result = openrouter_client.handle_command("status", []) + + assert result is True + mock_status.assert_called_once() + + +@patch(f"{_MOD}.make_call") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_handle_command_routes_call(mock_console, mock_header, mock_jh, mock_call): + """handle_command('call', ['hello']) delegates to make_call with args.""" + from aipass.api.apps.modules import openrouter_client + + result = openrouter_client.handle_command("call", ["hello"]) + + assert result is True + mock_call.assert_called_once_with(["hello"]) + + +# ============================================= +# handle_command — gates +# ============================================= + + +@patch(f"{_MOD}.print_help") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_handle_command_help_gate(mock_console, mock_header, mock_jh, mock_help): + """--help flag triggers print_help and returns True without logging.""" + from aipass.api.apps.modules import openrouter_client + + result = openrouter_client.handle_command("test", ["--help"]) + + assert result is True + mock_help.assert_called_once() + mock_jh.log_operation.assert_not_called() + + +@patch(f"{_MOD}.print_introspection") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_handle_command_introspection_gate(mock_console, mock_header, mock_jh, mock_intro): + """'call' with no args triggers introspection instead of make_call.""" + from aipass.api.apps.modules import openrouter_client + + result = openrouter_client.handle_command("call", []) + + assert result is True + mock_intro.assert_called_once() + + +# ============================================= +# handle_command — logging +# ============================================= + + +@patch(f"{_MOD}.test_connection") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_handle_command_logs_operation(mock_console, mock_header, mock_jh, mock_test): + """Valid commands log their operation via json_handler.log_operation.""" + from aipass.api.apps.modules import openrouter_client + + openrouter_client.handle_command("test", []) + + mock_jh.log_operation.assert_called_once_with( + "openrouter_test", {"command": "test"} + ) + + +# ============================================= +# test_connection +# ============================================= + + +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.models") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_test_connection_success(mock_console, mock_header, mock_keys, mock_models, mock_success): + """Successful connection prints success with model count.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = "sk-or-test-key" + mock_models.fetch_models_from_api.return_value = [{"id": "m1"}, {"id": "m2"}, {"id": "m3"}] + + openrouter_client.test_connection() + + mock_models.fetch_models_from_api.assert_called_once_with("sk-or-test-key") + mock_success.assert_called_once() + assert "3 models" in mock_success.call_args[0][0] + + +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.models") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_test_connection_success_no_error(mock_console, mock_header, mock_keys, mock_models, mock_success, mock_error): + """Successful connection must not call error().""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = "sk-or-test-key" + mock_models.fetch_models_from_api.return_value = [{"id": "m1"}] + + openrouter_client.test_connection() + + mock_error.assert_not_called() + + +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_test_connection_no_key(mock_console, mock_header, mock_keys, mock_error): + """Missing API key triggers error with diagnosis.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = None + mock_keys.diagnose_key.return_value = "No key found in env" + + openrouter_client.test_connection() + + mock_keys.diagnose_key.assert_called_once_with("openrouter") + mock_error.assert_called_once_with("No key found in env") + + +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_test_connection_no_key_no_success(mock_console, mock_header, mock_keys, mock_error, mock_success): + """Missing API key path must not call success().""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = None + mock_keys.diagnose_key.return_value = "No key found in env" + + openrouter_client.test_connection() + + mock_success.assert_not_called() + + +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.models") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_test_connection_api_failure_no_success(mock_console, mock_header, mock_keys, mock_models, mock_error, mock_success): + """API failure path must not call success().""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = "sk-or-test-key" + mock_models.fetch_models_from_api.return_value = None + + openrouter_client.test_connection() + + mock_success.assert_not_called() + + +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.models") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_test_connection_api_failure(mock_console, mock_header, mock_keys, mock_models, mock_error): + """API returning None triggers connection-failed error.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = "sk-or-test-key" + mock_models.fetch_models_from_api.return_value = None + + openrouter_client.test_connection() + + mock_error.assert_called_once() + assert "failed" in mock_error.call_args[0][0].lower() + + +# ============================================= +# list_models +# ============================================= + + +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.models") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_list_models_success(mock_console, mock_header, mock_keys, mock_models, mock_success): + """Successful model listing prints success and table rows.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = "sk-or-test-key" + fake_models = [ + { + "id": f"provider/model-{i}", + "context_length": 128000, + "pricing": {"prompt": "0.001", "completion": "0.002"}, + } + for i in range(3) + ] + mock_models.fetch_models_from_api.return_value = fake_models + + openrouter_client.list_models([]) + + mock_success.assert_called_once() + assert "3 models" in mock_success.call_args[0][0] + # Header row + separator + 3 data rows = at least 5 console.print calls after header + assert mock_console.print.call_count >= 5 + + +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.models") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_list_models_success_no_error(mock_console, mock_header, mock_keys, mock_models, mock_success, mock_error): + """Successful model listing must not call error().""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = "sk-or-test-key" + mock_models.fetch_models_from_api.return_value = [ + {"id": "p/m", "context_length": 4096, "pricing": {"prompt": "0", "completion": "0"}} + ] + + openrouter_client.list_models([]) + + mock_error.assert_not_called() + + +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_list_models_no_key_no_success(mock_console, mock_header, mock_keys, mock_error, mock_success): + """Missing API key on list_models must not call success().""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = None + mock_keys.diagnose_key.return_value = "Key not set" + + openrouter_client.list_models([]) + + mock_success.assert_not_called() + + +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_list_models_no_key(mock_console, mock_header, mock_keys, mock_error): + """Missing API key triggers error with diagnosis.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = None + mock_keys.diagnose_key.return_value = "Key not set" + + openrouter_client.list_models([]) + + mock_error.assert_called_once_with("Key not set") + + +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.models") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_list_models_limits_to_10(mock_console, mock_header, mock_keys, mock_models, mock_success): + """Without --all flag, only 10 models are displayed from a larger list.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = "sk-or-test-key" + fake_models = [ + { + "id": f"provider/model-{i}", + "context_length": 4096, + "pricing": {"prompt": "0", "completion": "0"}, + } + for i in range(25) + ] + mock_models.fetch_models_from_api.return_value = fake_models + + openrouter_client.list_models([]) + + # Count data rows: calls that contain a model ID pattern + data_row_calls = [ + c for c in mock_console.print.call_args_list + if c.args and isinstance(c.args[0], str) and "provider/model-" in c.args[0] + ] + assert len(data_row_calls) == 10 + + # Should show "Showing 10 of 25" truncation notice + all_output = " ".join( + str(c) for c in mock_console.print.call_args_list + ) + assert "10 of 25" in all_output + + +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.models") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_list_models_all_flag_shows_everything(mock_console, mock_header, mock_keys, mock_models, mock_success): + """With --all flag, all models are displayed.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = "sk-or-test-key" + fake_models = [ + { + "id": f"provider/model-{i}", + "context_length": 4096, + "pricing": {"prompt": "0", "completion": "0"}, + } + for i in range(25) + ] + mock_models.fetch_models_from_api.return_value = fake_models + + openrouter_client.list_models(["--all"]) + + data_row_calls = [ + c for c in mock_console.print.call_args_list + if c.args and isinstance(c.args[0], str) and "provider/model-" in c.args[0] + ] + assert len(data_row_calls) == 25 + + +# ============================================= +# check_status +# ============================================= + + +@patch(f"{_MOD}.client") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_check_status_with_key(mock_console, mock_header, mock_keys, mock_client): + """When API key exists, status shows masked key and cache stats.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = "sk-or-v1-abcdefgh12345678" + mock_client.get_cache_stats.return_value = {"cached_clients": 2, "max_cache_size": 5} + + openrouter_client.check_status() + + all_output = " ".join(str(c) for c in mock_console.print.call_args_list) + # Key should be shown as masked + assert "sk-or-v1" in all_output + assert "5678" in all_output + # "yes" for key configured + assert "yes" in all_output + # Cache stats shown + assert "2/5" in all_output + + +@patch(f"{_MOD}.client") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_check_status_no_key(mock_console, mock_header, mock_keys, mock_client): + """When API key is missing, status shows 'no' and diagnosis.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = None + mock_keys.diagnose_key.return_value = "OPENROUTER_API_KEY not set" + mock_client.get_cache_stats.return_value = {"cached_clients": 0, "max_cache_size": 5} + + openrouter_client.check_status() + + all_output = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "no" in all_output + assert "OPENROUTER_API_KEY not set" in all_output + + +# ============================================= +# get_response — delegation +# ============================================= + + +@patch(f"{_MOD}.client") +def test_get_response_delegates_to_handler(mock_client): + """get_response passes through to client.get_response and returns its result.""" + from aipass.api.apps.modules import openrouter_client + + mock_client.get_response.return_value = { + "content": "Hello!", + "id": "gen-123", + "model": "anthropic/claude-3.5-sonnet", + } + + result = openrouter_client.get_response( + "Hi there", + caller="flow", + model="anthropic/claude-3.5-sonnet", + temperature=0.5, + ) + + mock_client.get_response.assert_called_once_with( + "Hi there", + "flow", + "anthropic/claude-3.5-sonnet", + temperature=0.5, + ) + assert result is not None + assert result["content"] == "Hello!" + assert result["model"] == "anthropic/claude-3.5-sonnet" + + +@patch(f"{_MOD}.client") +def test_get_response_returns_none_on_failure(mock_client): + """get_response returns None when client handler returns None.""" + from aipass.api.apps.modules import openrouter_client + + mock_client.get_response.return_value = None + + result = openrouter_client.get_response("fail prompt", caller="test") + + assert result is None + + +# ============================================= +# list_models — context formatting +# ============================================= + + +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.models") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_list_models_formats_million_context(mock_console, mock_header, mock_keys, mock_models, mock_success): + """Context length >= 1M formatted as 'XM'.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = "sk-or-test" + mock_models.fetch_models_from_api.return_value = [ + {"id": "big/model", "context_length": 2_000_000, "pricing": {"prompt": "0", "completion": "0"}} + ] + + openrouter_client.list_models([]) + + data_rows = [ + c for c in mock_console.print.call_args_list + if c.args and isinstance(c.args[0], str) and "big/model" in c.args[0] + ] + assert len(data_rows) == 1 + assert "2M" in data_rows[0].args[0] + + +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.models") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_list_models_formats_thousand_context(mock_console, mock_header, mock_keys, mock_models, mock_success): + """Context length >= 1k formatted as 'Xk'.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = "sk-or-test" + mock_models.fetch_models_from_api.return_value = [ + {"id": "med/model", "context_length": 128_000, "pricing": {"prompt": "0.01", "completion": "0.02"}} + ] + + openrouter_client.list_models([]) + + data_rows = [ + c for c in mock_console.print.call_args_list + if c.args and isinstance(c.args[0], str) and "med/model" in c.args[0] + ] + assert len(data_rows) == 1 + assert "128k" in data_rows[0].args[0] + + +@patch(f"{_MOD}.success") +@patch(f"{_MOD}.models") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_list_models_formats_free_pricing(mock_console, mock_header, mock_keys, mock_models, mock_success): + """Models with zero pricing show 'free'.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = "sk-or-test" + mock_models.fetch_models_from_api.return_value = [ + {"id": "free/model", "context_length": 4096, "pricing": {"prompt": "0", "completion": "0"}} + ] + + openrouter_client.list_models([]) + + data_rows = [ + c for c in mock_console.print.call_args_list + if c.args and isinstance(c.args[0], str) and "free/model" in c.args[0] + ] + assert len(data_rows) == 1 + assert "free" in data_rows[0].args[0] + + +# ============================================= +# make_call — stub behaviour +# ============================================= + + +@patch(f"{_MOD}.warning") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_make_call_shows_todo_warning(mock_console, mock_header, mock_warning): + """make_call currently shows a TODO warning.""" + from aipass.api.apps.modules import openrouter_client + + openrouter_client.make_call(["What is AI?"]) + + mock_warning.assert_called_once() + assert "TODO" in mock_warning.call_args[0][0] + + +# ============================================= +# list_models — error on fetch failure +# ============================================= + + +@patch(f"{_MOD}.error") +@patch(f"{_MOD}.models") +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_list_models_fetch_failure(mock_console, mock_header, mock_keys, mock_models, mock_error): + """When fetch_models_from_api returns None, error is shown.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.return_value = "sk-or-test" + mock_models.fetch_models_from_api.return_value = None + + openrouter_client.list_models([]) + + mock_error.assert_called_once() + assert "fetch" in mock_error.call_args[0][0].lower() + + +# ============================================= +# handle_command — exception propagation +# ============================================= + + +@patch(f"{_MOD}.keys") +@patch(f"{_MOD}.json_handler") +@patch(f"{_MOD}.header") +@patch(f"{_MOD}.console") +def test_handle_command_propagates_exception(mock_console, mock_header, mock_jh, mock_keys): + """handle_command re-raises exceptions from downstream handlers.""" + from aipass.api.apps.modules import openrouter_client + + mock_keys.get_api_key.side_effect = RuntimeError("handler failed") + + with pytest.raises(RuntimeError, match="handler failed"): + openrouter_client.handle_command("test", []) diff --git a/src/aipass/api/tests/test_usage_tracker.py b/src/aipass/api/tests/test_usage_tracker.py new file mode 100644 index 00000000..be7872ad --- /dev/null +++ b/src/aipass/api/tests/test_usage_tracker.py @@ -0,0 +1,459 @@ +# =================== AIPass ==================== +# Name: test_usage_tracker.py +# Description: Tests for usage tracker module +# Version: 1.0.0 +# Created: 2026-03-24 +# Modified: 2026-03-24 +# ============================================= + +""" +Tests for usage_tracker.py -- API usage monitoring orchestration. + +Tests: +- handle_command routing for all subcommands +- Help gate, introspection gate, unknown command +- show_stats / show_session with data and without +- show_caller_usage with data, no data, and missing args +- cleanup_data success/failure, default/custom days +""" + +from pathlib import Path +from unittest.mock import patch, MagicMock, call + +import pytest + +from aipass.api.apps.modules.usage_tracker import handle_command as _hc # noqa: F401 — seedgo test_coverage detection + +# All external dependencies are patched at the module level so no +# live I/O or network access is needed. + +PATCH_ROOT = "aipass.api.apps.modules.usage_tracker" + + +# ============================================= +# Helpers +# ============================================= + + +def _base_patches(): + """Return the list of patch targets shared by most tests.""" + return [ + f"{PATCH_ROOT}.console", + f"{PATCH_ROOT}.header", + f"{PATCH_ROOT}.success", + f"{PATCH_ROOT}.error", + f"{PATCH_ROOT}.warning", + f"{PATCH_ROOT}.json_handler", + f"{PATCH_ROOT}.tracking", + f"{PATCH_ROOT}.aggregation", + f"{PATCH_ROOT}.cleanup", + ] + + +# ============================================= +# handle_command -- routing & gates +# ============================================= + + +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.json_handler") +def test_handle_command_returns_false_for_unknown(mock_jh, mock_header, mock_console): + """Unknown commands must return False without logging.""" + from aipass.api.apps.modules import usage_tracker + + result = usage_tracker.handle_command("unknown", []) + + assert result is False + mock_jh.log_operation.assert_not_called() + + +@patch(f"{PATCH_ROOT}.track_usage") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.json_handler") +def test_handle_command_routes_track(mock_jh, mock_header, mock_console, mock_track): + """'track' with args should call track_usage(args).""" + from aipass.api.apps.modules import usage_tracker + + result = usage_tracker.handle_command("track", ["my_app"]) + + assert result is True + mock_track.assert_called_once_with(["my_app"]) + + +@patch(f"{PATCH_ROOT}.show_stats") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.json_handler") +def test_handle_command_routes_stats_standalone(mock_jh, mock_header, mock_console, mock_show): + """'stats' is standalone -- routes before introspection gate, no args needed.""" + from aipass.api.apps.modules import usage_tracker + + result = usage_tracker.handle_command("stats", []) + + assert result is True + mock_show.assert_called_once() + + +@patch(f"{PATCH_ROOT}.show_session") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.json_handler") +def test_handle_command_routes_session_standalone(mock_jh, mock_header, mock_console, mock_show): + """'session' is standalone -- routes before introspection gate, no args needed.""" + from aipass.api.apps.modules import usage_tracker + + result = usage_tracker.handle_command("session", []) + + assert result is True + mock_show.assert_called_once() + + +@patch(f"{PATCH_ROOT}.show_caller_usage") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.json_handler") +def test_handle_command_routes_caller_usage(mock_jh, mock_header, mock_console, mock_show): + """'caller-usage' with args routes to show_caller_usage.""" + from aipass.api.apps.modules import usage_tracker + + result = usage_tracker.handle_command("caller-usage", ["my_app"]) + + assert result is True + mock_show.assert_called_once_with(["my_app"]) + + +@patch(f"{PATCH_ROOT}.cleanup_data") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.json_handler") +def test_handle_command_routes_cleanup(mock_jh, mock_header, mock_console, mock_cleanup): + """'cleanup' with args routes to cleanup_data.""" + from aipass.api.apps.modules import usage_tracker + + result = usage_tracker.handle_command("cleanup", ["60"]) + + assert result is True + mock_cleanup.assert_called_once_with(["60"]) + + +@patch(f"{PATCH_ROOT}.print_help") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.json_handler") +def test_handle_command_help_gate(mock_jh, mock_header, mock_console, mock_help): + """--help flag triggers print_help and returns True.""" + from aipass.api.apps.modules import usage_tracker + + result = usage_tracker.handle_command("track", ["--help"]) + + assert result is True + mock_help.assert_called_once() + # log_operation should NOT be called when help is shown + mock_jh.log_operation.assert_not_called() + + +@patch(f"{PATCH_ROOT}.print_introspection") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.json_handler") +def test_handle_command_introspection_gate(mock_jh, mock_header, mock_console, mock_intro): + """'track' with empty args triggers introspection gate.""" + from aipass.api.apps.modules import usage_tracker + + result = usage_tracker.handle_command("track", []) + + assert result is True + mock_intro.assert_called_once() + + +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.json_handler") +def test_handle_command_logs_operation(mock_jh, mock_header, mock_console): + """handle_command should call json_handler.log_operation for valid commands.""" + from aipass.api.apps.modules import usage_tracker + + usage_tracker.handle_command("stats", []) + + mock_jh.log_operation.assert_called_once_with( + "usage_stats", {"command": "stats"} + ) + + +# ============================================= +# show_stats +# ============================================= + + +@patch(f"{PATCH_ROOT}.warning") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.aggregation") +def test_show_stats_with_data(mock_agg, mock_header, mock_console, mock_warning): + """show_stats prints stats when aggregation returns data.""" + from aipass.api.apps.modules import usage_tracker + + mock_agg.get_session_summary.return_value = { + "total_requests": 42, + "total_cost": 0.123456, + "total_tokens": 9001, + } + + usage_tracker.show_stats() + + mock_header.assert_called_once_with("Usage Statistics") + # Verify the data values appear in console output + calls = [str(c) for c in mock_console.print.call_args_list] + output = " ".join(calls) + assert "42" in output + assert "9001" in output + mock_warning.assert_not_called() + + +@patch(f"{PATCH_ROOT}.warning") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.aggregation") +def test_show_stats_no_data(mock_agg, mock_header, mock_console, mock_warning): + """show_stats shows warning when no data available.""" + from aipass.api.apps.modules import usage_tracker + + mock_agg.get_session_summary.return_value = {} + + usage_tracker.show_stats() + + mock_warning.assert_called_once_with("No usage data available") + # Verify no stat data was printed to console + stat_calls = [ + c for c in mock_console.print.call_args_list + if c.args and isinstance(c.args[0], str) and "Total Requests" in c.args[0] + ] + assert len(stat_calls) == 0, "No stat rows should be printed when data is empty" + + +# ============================================= +# show_session +# ============================================= + + +@patch(f"{PATCH_ROOT}.warning") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.aggregation") +def test_show_session_with_data(mock_agg, mock_header, mock_console, mock_warning): + """show_session prints session data when available.""" + from aipass.api.apps.modules import usage_tracker + + mock_agg.get_session_summary.return_value = { + "total_requests": 10, + "total_cost": 0.05, + "total_tokens": 3000, + } + + usage_tracker.show_session() + + mock_header.assert_called_once_with("Session Summary") + calls = [str(c) for c in mock_console.print.call_args_list] + output = " ".join(calls) + assert "10" in output + assert "3000" in output + mock_warning.assert_not_called() + + +@patch(f"{PATCH_ROOT}.warning") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.aggregation") +def test_show_session_no_data(mock_agg, mock_header, mock_console, mock_warning): + """show_session shows warning when no session data.""" + from aipass.api.apps.modules import usage_tracker + + mock_agg.get_session_summary.return_value = {} + + usage_tracker.show_session() + + mock_warning.assert_called_once_with("No session data available") + # Verify no session stat data was printed to console + stat_calls = [ + c for c in mock_console.print.call_args_list + if c.args and isinstance(c.args[0], str) and "Session Requests" in c.args[0] + ] + assert len(stat_calls) == 0, "No session rows should be printed when data is empty" + + +# ============================================= +# show_caller_usage +# ============================================= + + +@patch(f"{PATCH_ROOT}.warning") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.aggregation") +def test_show_caller_usage_with_data(mock_agg, mock_header, mock_console, mock_warning): + """show_caller_usage prints caller data when available.""" + from aipass.api.apps.modules import usage_tracker + + mock_agg.get_caller_usage.return_value = { + "requests": 5, + "total_cost": 0.01, + "total_tokens": 1500, + } + + usage_tracker.show_caller_usage(["my_app"]) + + mock_agg.get_caller_usage.assert_called_once_with("my_app") + mock_header.assert_called_once_with("Usage for Caller: my_app") + calls = [str(c) for c in mock_console.print.call_args_list] + output = " ".join(calls) + assert "5" in output + assert "1500" in output + mock_warning.assert_not_called() + + +@patch(f"{PATCH_ROOT}.warning") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.aggregation") +def test_show_caller_usage_no_data(mock_agg, mock_header, mock_console, mock_warning): + """show_caller_usage shows warning when no data found.""" + from aipass.api.apps.modules import usage_tracker + + mock_agg.get_caller_usage.return_value = {} + + usage_tracker.show_caller_usage(["ghost_caller"]) + + mock_warning.assert_called_once_with("No usage data found for caller: ghost_caller") + # Verify no usage data rows were printed to console + usage_calls = [ + c for c in mock_console.print.call_args_list + if c.args and isinstance(c.args[0], str) and "Requests" in c.args[0] + ] + assert len(usage_calls) == 0, "No usage rows should be printed when data is empty" + + +@patch(f"{PATCH_ROOT}.error") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.aggregation") +def test_show_caller_usage_no_args(mock_agg, mock_header, mock_console, mock_error): + """show_caller_usage calls error() when called with empty args.""" + from aipass.api.apps.modules import usage_tracker + + usage_tracker.show_caller_usage([]) + + mock_error.assert_called_once_with("Caller name required") + mock_agg.get_caller_usage.assert_not_called() + + +# ============================================= +# cleanup_data +# ============================================= + + +@patch(f"{PATCH_ROOT}.error") +@patch(f"{PATCH_ROOT}.success") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.cleanup") +def test_cleanup_success(mock_cleanup_handler, mock_header, mock_console, mock_success, mock_error): + """cleanup_data calls success() when handler returns truthy.""" + from aipass.api.apps.modules import usage_tracker + + mock_cleanup_handler.cleanup_old_data.return_value = True + + with patch(f"{PATCH_ROOT}.Path") as mock_path_cls: + mock_path_cls.__file__ = MagicMock() + # Let Path(__file__).resolve().parent chain work + mock_resolved = MagicMock() + mock_path_cls.return_value.resolve.return_value.parent.parent.parent.__truediv__ = MagicMock() + + # Simpler approach: just let the real Path work -- it resolves against + # the actual source file, but cleanup_old_data is mocked anyway. + pass + + # Call directly without patching Path -- cleanup handler is mocked + usage_tracker.cleanup_data(["45"]) + + mock_cleanup_handler.cleanup_old_data.assert_called_once() + mock_success.assert_called_once_with("Cleaned up data older than 45 days") + mock_error.assert_not_called() + + +@patch(f"{PATCH_ROOT}.error") +@patch(f"{PATCH_ROOT}.success") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.cleanup") +def test_cleanup_failure(mock_cleanup_handler, mock_header, mock_console, mock_success, mock_error): + """cleanup_data calls error() when handler returns falsy.""" + from aipass.api.apps.modules import usage_tracker + + mock_cleanup_handler.cleanup_old_data.return_value = False + + usage_tracker.cleanup_data(["30"]) + + mock_cleanup_handler.cleanup_old_data.assert_called_once() + mock_error.assert_called_once_with("Cleanup failed") + mock_success.assert_not_called() + + +@patch(f"{PATCH_ROOT}.error") +@patch(f"{PATCH_ROOT}.success") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.cleanup") +def test_cleanup_default_30_days(mock_cleanup_handler, mock_header, mock_console, mock_success, mock_error): + """cleanup_data defaults to 30 days when no args provided.""" + from aipass.api.apps.modules import usage_tracker + + mock_cleanup_handler.cleanup_old_data.return_value = True + + usage_tracker.cleanup_data([]) + + # Verify the header shows 30 days + mock_header.assert_called_once_with("Cleanup Old Data (retain 30 days)") + # Verify cleanup_old_data was called with days=30 + args, kwargs = mock_cleanup_handler.cleanup_old_data.call_args + assert args[1] == 30 + mock_success.assert_called_once_with("Cleaned up data older than 30 days") + + +@patch(f"{PATCH_ROOT}.error") +@patch(f"{PATCH_ROOT}.success") +@patch(f"{PATCH_ROOT}.console") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.cleanup") +def test_cleanup_custom_days(mock_cleanup_handler, mock_header, mock_console, mock_success, mock_error): + """cleanup_data parses custom days from args.""" + from aipass.api.apps.modules import usage_tracker + + mock_cleanup_handler.cleanup_old_data.return_value = True + + usage_tracker.cleanup_data(["90"]) + + mock_header.assert_called_once_with("Cleanup Old Data (retain 90 days)") + args, kwargs = mock_cleanup_handler.cleanup_old_data.call_args + assert args[1] == 90 + mock_success.assert_called_once_with("Cleaned up data older than 90 days") + + +# ============================================= +# handle_command — exception propagation +# ============================================= + + +@patch(f"{PATCH_ROOT}.aggregation") +@patch(f"{PATCH_ROOT}.json_handler") +@patch(f"{PATCH_ROOT}.header") +@patch(f"{PATCH_ROOT}.console") +def test_handle_command_propagates_exception(mock_console, mock_header, mock_jh, mock_agg): + """handle_command re-raises exceptions from downstream handlers.""" + from aipass.api.apps.modules import usage_tracker + + mock_agg.get_session_summary.side_effect = RuntimeError("handler failed") + + with pytest.raises(RuntimeError, match="handler failed"): + usage_tracker.handle_command("stats", [])