chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
This commit is contained in:
+423
@@ -0,0 +1,423 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip(
|
||||
"google.cloud.agentidentitycredentials_v1",
|
||||
reason="Requires google-cloud-agentidentitycredentials",
|
||||
)
|
||||
|
||||
from google.adk.agents.callback_context import CallbackContext
|
||||
from google.adk.auth.auth_credential import AuthCredentialTypes
|
||||
from google.adk.auth.auth_tool import AuthConfig
|
||||
from google.adk.auth.auth_tool import AuthToolArguments
|
||||
from google.adk.flows.llm_flows.functions import REQUEST_EUC_FUNCTION_CALL_NAME
|
||||
from google.adk.integrations.agent_identity import _agent_identity_credentials_provider
|
||||
from google.adk.integrations.agent_identity import GcpAuthProviderScheme
|
||||
from google.adk.integrations.agent_identity._agent_identity_credentials_provider import _AgentIdentityCredentialsProvider
|
||||
from google.adk.integrations.agent_identity._agent_identity_credentials_provider import Client
|
||||
from google.adk.sessions.session import Session
|
||||
from google.cloud.agentidentitycredentials_v1 import RetrieveCredentialsResponse
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
return Mock(spec=Client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider(mock_client):
|
||||
return _AgentIdentityCredentialsProvider(client=mock_client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_scheme():
|
||||
scheme = GcpAuthProviderScheme(
|
||||
name="projects/test-project/locations/global/authProviders/test-provider",
|
||||
scopes=["test-scope"],
|
||||
continue_uri="https://example.com/continue",
|
||||
)
|
||||
return scheme
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_response(mock_client):
|
||||
resp = RetrieveCredentialsResponse()
|
||||
mock_client.retrieve_credentials.return_value = resp
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context():
|
||||
context = Mock(spec=CallbackContext)
|
||||
context.user_id = "user"
|
||||
context.function_call_id = "call_123"
|
||||
session = Mock(spec=Session)
|
||||
session.events = []
|
||||
context.session = session
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@patch.dict(_agent_identity_credentials_provider.os.environ, clear=True)
|
||||
@patch.object(_agent_identity_credentials_provider, "Client")
|
||||
def test_get_client_uses_rest_transport(mock_client_class):
|
||||
provider = (
|
||||
_agent_identity_credentials_provider._AgentIdentityCredentialsProvider()
|
||||
)
|
||||
provider._get_client()
|
||||
|
||||
mock_client_class.assert_called_once()
|
||||
_, kwargs = mock_client_class.call_args
|
||||
assert kwargs.get("transport") == "rest"
|
||||
|
||||
|
||||
@patch.dict(
|
||||
_agent_identity_credentials_provider.os.environ,
|
||||
{"AGENT_IDENTITY_CREDENTIALS_TARGET_HOST": "some-host"},
|
||||
)
|
||||
@patch.object(_agent_identity_credentials_provider, "Client")
|
||||
@patch.object(_agent_identity_credentials_provider, "ClientOptions")
|
||||
def test_get_client_with_env_var(mock_client_options_class, mock_client_class):
|
||||
provider = (
|
||||
_agent_identity_credentials_provider._AgentIdentityCredentialsProvider()
|
||||
)
|
||||
client = provider._get_client()
|
||||
|
||||
assert client == mock_client_class.return_value
|
||||
mock_client_options_class.assert_called_once_with(api_endpoint="some-host")
|
||||
mock_client_class.assert_called_once_with(
|
||||
client_options=mock_client_options_class.return_value, transport="rest"
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Non-interactive auth flows (API key and 2-legged OAuth)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_context_is_missing(
|
||||
provider, auth_scheme
|
||||
):
|
||||
"""Test get_auth_credential raises ValueError if context is missing."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="GcpAuthProvider requires a context with a valid user_id",
|
||||
):
|
||||
await provider.get_auth_credential(auth_scheme, context=None)
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_user_id_is_missing(
|
||||
provider, auth_scheme
|
||||
):
|
||||
"""Test get_auth_credential raises ValueError if user_id is missing."""
|
||||
context = Mock(spec=CallbackContext)
|
||||
context.user_id = None
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="GcpAuthProvider requires a context with a valid user_id",
|
||||
):
|
||||
await provider.get_auth_credential(auth_scheme, context=context)
|
||||
|
||||
|
||||
async def test_get_auth_credential_returns_credential_if_available_immediately(
|
||||
mock_client,
|
||||
auth_scheme,
|
||||
context,
|
||||
provider,
|
||||
):
|
||||
"""Test get_auth_credential returns credential if available immediately."""
|
||||
mock_response = RetrieveCredentialsResponse(
|
||||
{"success": {"header": "Authorization: Bearer", "token": "test-token"}}
|
||||
)
|
||||
mock_client.retrieve_credentials.return_value = mock_response
|
||||
|
||||
auth_credential = await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
assert auth_credential.auth_type == AuthCredentialTypes.HTTP
|
||||
assert auth_credential.http.scheme == "Bearer"
|
||||
assert auth_credential.http.credentials.token == "test-token"
|
||||
mock_client.retrieve_credentials.assert_called_once()
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_upstream_returns_empty_header(
|
||||
mock_client,
|
||||
auth_scheme,
|
||||
context,
|
||||
provider,
|
||||
):
|
||||
"""Test get_auth_credential raises ValueError for empty header."""
|
||||
mock_response = RetrieveCredentialsResponse(
|
||||
{"success": {"header": "", "token": "test-token"}}
|
||||
)
|
||||
mock_client.retrieve_credentials.return_value = mock_response
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Received either empty header or token from Agent Identity"
|
||||
" Credentials service."
|
||||
),
|
||||
):
|
||||
await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_upstream_returns_empty_token(
|
||||
mock_client,
|
||||
auth_scheme,
|
||||
context,
|
||||
provider,
|
||||
):
|
||||
"""Test get_auth_credential raises ValueError for empty token."""
|
||||
mock_response = RetrieveCredentialsResponse(
|
||||
{"success": {"header": "Authorization: Bearer", "token": ""}}
|
||||
)
|
||||
mock_client.retrieve_credentials.return_value = mock_response
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Received either empty header or token from Agent Identity"
|
||||
" Credentials service."
|
||||
),
|
||||
):
|
||||
await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
|
||||
async def test_get_auth_credential_returns_credential_if_upstream_returns_custom_header(
|
||||
mock_client,
|
||||
auth_scheme,
|
||||
context,
|
||||
provider,
|
||||
):
|
||||
"""Test get_auth_credential returns valid credential for custom header and sets X-GOOG-API-KEY header."""
|
||||
mock_response = RetrieveCredentialsResponse(
|
||||
{"success": {"header": "some-x-api-key", "token": "test-token"}}
|
||||
)
|
||||
mock_client.retrieve_credentials.return_value = mock_response
|
||||
|
||||
auth_credential = await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
assert auth_credential.auth_type == AuthCredentialTypes.HTTP
|
||||
assert not auth_credential.http.scheme
|
||||
assert auth_credential.http.credentials.token is None
|
||||
assert auth_credential.http.additional_headers == {
|
||||
"some-x-api-key": "test-token",
|
||||
"X-GOOG-API-KEY": "test-token",
|
||||
}
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_upstream_operation_errors(
|
||||
mock_client, auth_scheme, context, provider
|
||||
):
|
||||
"""Test get_auth_credential raises RuntimeError for rejected operations."""
|
||||
mock_client.retrieve_credentials.return_value = RetrieveCredentialsResponse(
|
||||
{"consent_rejected": {}}
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match="Operation failed: User consent rejected."
|
||||
):
|
||||
await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_upstream_call_fails(
|
||||
mock_client, auth_scheme, context, provider
|
||||
):
|
||||
"""Test get_auth_credential raises RuntimeError for failed calls."""
|
||||
mock_client.retrieve_credentials.side_effect = Exception(
|
||||
"API Quota Exhausted"
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Failed to retrieve credential for user 'user' on provider",
|
||||
) as exc_info:
|
||||
await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
# Assert that the original Exception is the chained cause!
|
||||
assert str(exc_info.value.__cause__) == "API Quota Exhausted"
|
||||
|
||||
|
||||
@patch.object(_agent_identity_credentials_provider.time, "time")
|
||||
async def test_get_auth_credential_raises_error_if_polling_times_out(
|
||||
mock_time,
|
||||
mock_client,
|
||||
auth_scheme,
|
||||
context,
|
||||
provider,
|
||||
):
|
||||
"""Test get_auth_credential raises RuntimeError if polling times out."""
|
||||
# First call sets start_time=0.0, second call checks time > timeout
|
||||
mock_time.side_effect = [0.0, 20.0]
|
||||
|
||||
mock_client.retrieve_credentials.return_value = RetrieveCredentialsResponse(
|
||||
{"pending": {}}
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Failed to retrieve credential for user 'user' on provider",
|
||||
) as exc_info:
|
||||
await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
assert "Timeout waiting for credentials." in str(exc_info.value.__cause__)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Interactive Auth Flows (3-legged OAuth for User Consents)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
async def test_get_auth_credential_initiates_user_consent(
|
||||
mock_client, auth_scheme, context, provider
|
||||
):
|
||||
expected_uri = "https://example.com/auth"
|
||||
expected_nonce = "sample-nonce-123"
|
||||
mock_client.retrieve_credentials.return_value = RetrieveCredentialsResponse({
|
||||
"uri_consent_required": {
|
||||
"authorization_uri": expected_uri,
|
||||
"consent_nonce": expected_nonce,
|
||||
}
|
||||
})
|
||||
|
||||
# Assert that there is no prior user consent completion event
|
||||
assert not context.session.events
|
||||
|
||||
credential = await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
assert credential is not None
|
||||
assert credential.auth_type == AuthCredentialTypes.OAUTH2
|
||||
assert credential.oauth2.auth_uri == expected_uri
|
||||
assert credential.oauth2.nonce == expected_nonce
|
||||
|
||||
|
||||
async def test_get_auth_credential_returns_fresh_auth_uri_for_repeated_requests(
|
||||
mock_client, auth_scheme, context, provider
|
||||
):
|
||||
"""Test that repeated calls fetch fresh auth URIs if consent is still pending."""
|
||||
# Arrange: Explicit initial URI
|
||||
initial_uri = "https://example.com/auth"
|
||||
initial_nonce = "initial-nonce-123"
|
||||
mock_client.retrieve_credentials.return_value = RetrieveCredentialsResponse({
|
||||
"uri_consent_required": {
|
||||
"authorization_uri": initial_uri,
|
||||
"consent_nonce": initial_nonce,
|
||||
}
|
||||
})
|
||||
|
||||
credential1 = await provider.get_auth_credential(auth_scheme, context)
|
||||
assert credential1.oauth2.auth_uri == initial_uri
|
||||
assert credential1.oauth2.nonce == initial_nonce
|
||||
|
||||
# Arrange: Explicit new URI for the second call
|
||||
fresh_auth_uri = "https://example.com/auth_new"
|
||||
fresh_nonce = "fresh-nonce-456"
|
||||
mock_client.retrieve_credentials.return_value = RetrieveCredentialsResponse({
|
||||
"uri_consent_required": {
|
||||
"authorization_uri": fresh_auth_uri,
|
||||
"consent_nonce": fresh_nonce,
|
||||
}
|
||||
})
|
||||
|
||||
credential2 = await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
assert mock_client.retrieve_credentials.call_count == 2
|
||||
assert credential2.oauth2.auth_uri == fresh_auth_uri
|
||||
assert credential2.oauth2.nonce == fresh_nonce
|
||||
|
||||
|
||||
async def test_get_auth_credential_returns_token_if_consent_was_completed(
|
||||
mock_client, auth_scheme, context, provider
|
||||
):
|
||||
mock_client.retrieve_credentials.return_value = RetrieveCredentialsResponse(
|
||||
{"success": {"header": "Authorization: Bearer", "token": "test-token"}}
|
||||
)
|
||||
|
||||
# Create mock events
|
||||
function_call = Mock()
|
||||
function_call.id = "auth-req-1"
|
||||
function_call.name = REQUEST_EUC_FUNCTION_CALL_NAME
|
||||
function_call.args = AuthToolArguments(
|
||||
function_call_id="call-123",
|
||||
auth_config=Mock(spec=AuthConfig, auth_scheme=auth_scheme),
|
||||
).model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
event1 = Mock()
|
||||
event1.get_function_calls.return_value = [function_call]
|
||||
event1.get_function_responses.return_value = []
|
||||
|
||||
function_response = Mock()
|
||||
function_response.id = "auth-req-1"
|
||||
function_response.name = REQUEST_EUC_FUNCTION_CALL_NAME
|
||||
|
||||
event2 = Mock()
|
||||
event2.get_function_calls.return_value = []
|
||||
event2.get_function_responses.return_value = [function_response]
|
||||
|
||||
# Setup tool context and event history (order of events matters)
|
||||
context.session.events = [event1, event2]
|
||||
context.function_call_id = "call-123"
|
||||
|
||||
# Execute
|
||||
auth_credential = await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
# Verify
|
||||
assert auth_credential is not None
|
||||
assert auth_credential.auth_type == AuthCredentialTypes.HTTP
|
||||
assert auth_credential.http.scheme == "Bearer"
|
||||
assert auth_credential.http.credentials.token == "test-token"
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_consent_canceled(
|
||||
mock_client, auth_scheme, context, provider
|
||||
):
|
||||
function_call = Mock()
|
||||
function_call.id = "auth-req-1"
|
||||
function_call.name = REQUEST_EUC_FUNCTION_CALL_NAME
|
||||
function_call.args = AuthToolArguments(
|
||||
function_call_id="call-123",
|
||||
auth_config=Mock(spec=AuthConfig, auth_scheme=auth_scheme),
|
||||
).model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
event1 = Mock()
|
||||
event1.get_function_calls.return_value = [function_call]
|
||||
event1.get_function_responses.return_value = []
|
||||
|
||||
function_response = Mock()
|
||||
function_response.id = "auth-req-1"
|
||||
function_response.name = REQUEST_EUC_FUNCTION_CALL_NAME
|
||||
|
||||
event2 = Mock()
|
||||
event2.get_function_calls.return_value = []
|
||||
event2.get_function_responses.return_value = [function_response]
|
||||
|
||||
context.session.events = [event1, event2]
|
||||
context.function_call_id = "call-123"
|
||||
|
||||
mock_client.retrieve_credentials.return_value = RetrieveCredentialsResponse({
|
||||
"uri_consent_required": {
|
||||
"authorization_uri": "https://example.com/auth",
|
||||
"consent_nonce": "sample-nonce",
|
||||
}
|
||||
})
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match="Failed to retrieve consent based credential."
|
||||
):
|
||||
await provider.get_auth_credential(auth_scheme, context)
|
||||
@@ -0,0 +1,111 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""Unit tests for the GcpAuthProvider class."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.agents.callback_context import CallbackContext
|
||||
from google.adk.auth.auth_credential import AuthCredential
|
||||
from google.adk.auth.auth_tool import AuthConfig
|
||||
from google.adk.integrations.agent_identity import GcpAuthProvider
|
||||
from google.adk.integrations.agent_identity import GcpAuthProviderScheme
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_config():
|
||||
config = Mock(spec=AuthConfig)
|
||||
config.auth_scheme = Mock(spec=GcpAuthProviderScheme)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context():
|
||||
context = Mock(spec=CallbackContext)
|
||||
context.user_id = "user"
|
||||
return context
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gcp_auth_provider():
|
||||
return GcpAuthProvider()
|
||||
|
||||
|
||||
def test_supported_auth_schemes(gcp_auth_provider):
|
||||
"""Verify the provider supports the correct auth scheme."""
|
||||
assert GcpAuthProviderScheme in gcp_auth_provider.supported_auth_schemes
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_for_invalid_auth_scheme(
|
||||
context,
|
||||
):
|
||||
"""Test get_auth_credential raises ValueError for invalid auth scheme."""
|
||||
provider = GcpAuthProvider()
|
||||
invalid_auth_config = Mock(spec=AuthConfig)
|
||||
invalid_auth_config.auth_scheme = Mock() # Not GcpAuthProviderScheme
|
||||
|
||||
with pytest.raises(ValueError, match="Expected GcpAuthProviderScheme, got"):
|
||||
await provider.get_auth_credential(invalid_auth_config, context)
|
||||
|
||||
|
||||
@patch(
|
||||
"google.adk.integrations.agent_identity.gcp_auth_provider._IamConnectorCredentialsProvider"
|
||||
)
|
||||
async def test_get_auth_credential_routes_to_iam_connector_service_provider(
|
||||
mock_iam_cls, auth_config, context
|
||||
):
|
||||
"""Test routing to IAM Connector Credentials service for legacy auth provider resource names."""
|
||||
auth_config.auth_scheme.name = (
|
||||
"projects/test-project/locations/test-location/connectors/test-connector"
|
||||
)
|
||||
provider = GcpAuthProvider()
|
||||
|
||||
mock_credential = Mock(spec=AuthCredential)
|
||||
mock_iam_provider = mock_iam_cls.return_value
|
||||
mock_iam_provider.get_auth_credential = AsyncMock(
|
||||
return_value=mock_credential
|
||||
)
|
||||
|
||||
result = await provider.get_auth_credential(auth_config, context)
|
||||
|
||||
assert result == mock_credential
|
||||
mock_iam_provider.get_auth_credential.assert_awaited_once_with(
|
||||
auth_scheme=auth_config.auth_scheme, context=context
|
||||
)
|
||||
|
||||
|
||||
@patch(
|
||||
"google.adk.integrations.agent_identity.gcp_auth_provider._AgentIdentityCredentialsProvider"
|
||||
)
|
||||
async def test_get_auth_credential_routes_to_agent_identity_service_provider(
|
||||
mock_agent_cls, auth_config, context
|
||||
):
|
||||
"""Test routing to Agent Identity Credentials service for new auth provider resource names."""
|
||||
auth_config.auth_scheme.name = "projects/test-project/locations/test-location/authProviders/test-provider"
|
||||
provider = GcpAuthProvider()
|
||||
|
||||
mock_credential = Mock(spec=AuthCredential)
|
||||
mock_agent_provider = mock_agent_cls.return_value
|
||||
mock_agent_provider.get_auth_credential = AsyncMock(
|
||||
return_value=mock_credential
|
||||
)
|
||||
|
||||
result = await provider.get_auth_credential(auth_config, context)
|
||||
|
||||
assert result == mock_credential
|
||||
mock_agent_provider.get_auth_credential.assert_awaited_once_with(
|
||||
auth_scheme=auth_config.auth_scheme, context=context
|
||||
)
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip(
|
||||
"google.cloud.iamconnectorcredentials_v1alpha",
|
||||
reason="Requires google-cloud-iamconnectorcredentials",
|
||||
)
|
||||
|
||||
from google.adk.agents.callback_context import CallbackContext
|
||||
from google.adk.auth.auth_credential import AuthCredentialTypes
|
||||
from google.adk.auth.auth_tool import AuthConfig
|
||||
from google.adk.auth.auth_tool import AuthToolArguments
|
||||
from google.adk.flows.llm_flows.functions import REQUEST_EUC_FUNCTION_CALL_NAME
|
||||
from google.adk.integrations.agent_identity import _iam_connector_credentials_provider
|
||||
from google.adk.integrations.agent_identity import GcpAuthProviderScheme
|
||||
from google.adk.integrations.agent_identity._iam_connector_credentials_provider import _IamConnectorCredentialsProvider
|
||||
from google.adk.integrations.agent_identity._iam_connector_credentials_provider import Client
|
||||
from google.adk.sessions.session import Session
|
||||
from google.cloud.iamconnectorcredentials_v1alpha import RetrieveCredentialsMetadata
|
||||
from google.cloud.iamconnectorcredentials_v1alpha import RetrieveCredentialsResponse
|
||||
from google.longrunning.operations_pb2 import Operation
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
return Mock(spec=Client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider(mock_client):
|
||||
return _IamConnectorCredentialsProvider(client=mock_client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_scheme():
|
||||
scheme = GcpAuthProviderScheme(
|
||||
name="projects/test-project/locations/global/connectors/test-connector",
|
||||
scopes=["test-scope"],
|
||||
continue_uri="https://example.com/continue",
|
||||
)
|
||||
return scheme
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_operation(mock_client):
|
||||
op = Operation(done=True)
|
||||
|
||||
class DummyCall:
|
||||
|
||||
def __init__(self, operation):
|
||||
self.operation = operation
|
||||
|
||||
mock_client.retrieve_credentials.return_value = DummyCall(op)
|
||||
return op
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context():
|
||||
context = Mock(spec=CallbackContext)
|
||||
context.user_id = "user"
|
||||
context.function_call_id = "call_123"
|
||||
session = Mock(spec=Session)
|
||||
session.events = []
|
||||
context.session = session
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@patch.dict(_iam_connector_credentials_provider.os.environ, clear=True)
|
||||
@patch.object(_iam_connector_credentials_provider, "Client")
|
||||
def test_get_client_uses_rest_transport(mock_client_class):
|
||||
provider = (
|
||||
_iam_connector_credentials_provider._IamConnectorCredentialsProvider()
|
||||
)
|
||||
provider._get_client()
|
||||
|
||||
mock_client_class.assert_called_once()
|
||||
_, kwargs = mock_client_class.call_args
|
||||
assert kwargs.get("transport") == "rest"
|
||||
|
||||
|
||||
@patch.dict(
|
||||
_iam_connector_credentials_provider.os.environ,
|
||||
{"IAM_CONNECTOR_CREDENTIALS_TARGET_HOST": "some-host"},
|
||||
)
|
||||
@patch.object(_iam_connector_credentials_provider, "Client")
|
||||
@patch.object(_iam_connector_credentials_provider, "ClientOptions")
|
||||
def test_get_client_with_env_var(mock_client_options_class, mock_client_class):
|
||||
provider = (
|
||||
_iam_connector_credentials_provider._IamConnectorCredentialsProvider()
|
||||
)
|
||||
client = provider._get_client()
|
||||
|
||||
assert client == mock_client_class.return_value
|
||||
mock_client_options_class.assert_called_once_with(api_endpoint="some-host")
|
||||
mock_client_class.assert_called_once_with(
|
||||
client_options=mock_client_options_class.return_value, transport="rest"
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Non-interactive auth flows (API key and 2-legged OAuth)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_context_is_missing(
|
||||
provider, auth_scheme
|
||||
):
|
||||
"""Test get_auth_credential raises ValueError if context is missing."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="GcpAuthProvider requires a context with a valid user_id",
|
||||
):
|
||||
await provider.get_auth_credential(auth_scheme, context=None)
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_user_id_is_missing(
|
||||
provider, auth_scheme
|
||||
):
|
||||
"""Test get_auth_credential raises ValueError if user_id is missing."""
|
||||
context = Mock(spec=CallbackContext)
|
||||
context.user_id = None
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="GcpAuthProvider requires a context with a valid user_id",
|
||||
):
|
||||
await provider.get_auth_credential(auth_scheme, context=context)
|
||||
|
||||
|
||||
async def test_get_auth_credential_returns_credential_if_available_immediately(
|
||||
mock_client,
|
||||
mock_operation,
|
||||
auth_scheme,
|
||||
context,
|
||||
provider,
|
||||
):
|
||||
"""Test get_auth_credential returns credential if available immediately."""
|
||||
mock_credential = RetrieveCredentialsResponse(
|
||||
header="Authorization: Bearer", token="test-token"
|
||||
)
|
||||
mock_operation.response.value = RetrieveCredentialsResponse.serialize(
|
||||
mock_credential
|
||||
)
|
||||
|
||||
auth_credential = await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
assert auth_credential.auth_type == AuthCredentialTypes.HTTP
|
||||
assert auth_credential.http.scheme == "Bearer"
|
||||
assert auth_credential.http.credentials.token == "test-token"
|
||||
mock_client.retrieve_credentials.assert_called_once()
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_upstream_returns_empty_header(
|
||||
mock_operation,
|
||||
auth_scheme,
|
||||
context,
|
||||
provider,
|
||||
):
|
||||
"""Test get_auth_credential raises RuntimeError for empty header."""
|
||||
mock_credential = RetrieveCredentialsResponse(header="", token="test-token")
|
||||
mock_operation.response.value = RetrieveCredentialsResponse.serialize(
|
||||
mock_credential
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Received either empty header or token from IAM Connector"
|
||||
" Credentials service."
|
||||
),
|
||||
):
|
||||
await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_upstream_returns_empty_token(
|
||||
mock_operation,
|
||||
auth_scheme,
|
||||
context,
|
||||
provider,
|
||||
):
|
||||
"""Test get_auth_credential raises RuntimeError for empty token."""
|
||||
mock_credential = RetrieveCredentialsResponse(
|
||||
header="Authorization: Bearer", token=""
|
||||
)
|
||||
mock_operation.response.value = RetrieveCredentialsResponse.serialize(
|
||||
mock_credential
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Received either empty header or token from IAM Connector"
|
||||
" Credentials service."
|
||||
),
|
||||
):
|
||||
await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
|
||||
async def test_get_auth_credential_returns_credential_if_upstream_returns_custom_header(
|
||||
mock_operation,
|
||||
auth_scheme,
|
||||
context,
|
||||
provider,
|
||||
):
|
||||
"""Test get_auth_credential returns valid credential for custom header and sets X-GOOG-API-KEY header."""
|
||||
mock_credential = RetrieveCredentialsResponse(
|
||||
header="some-x-api-key", token="test-token"
|
||||
)
|
||||
mock_operation.response.value = RetrieveCredentialsResponse.serialize(
|
||||
mock_credential
|
||||
)
|
||||
|
||||
auth_credential = await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
assert auth_credential.auth_type == AuthCredentialTypes.HTTP
|
||||
assert not auth_credential.http.scheme
|
||||
assert auth_credential.http.credentials.token is None
|
||||
assert auth_credential.http.additional_headers == {
|
||||
"some-x-api-key": "test-token",
|
||||
"X-GOOG-API-KEY": "test-token",
|
||||
}
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_upstream_operation_errors(
|
||||
mock_operation, auth_scheme, context, provider
|
||||
):
|
||||
"""Test get_auth_credential raises RuntimeError for failed operations."""
|
||||
mock_operation.error.message = "OAuth server error"
|
||||
mock_operation.done = False
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match="Operation failed: OAuth server error"
|
||||
):
|
||||
await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_upstream_call_fails(
|
||||
mock_client, auth_scheme, context, provider
|
||||
):
|
||||
"""Test get_auth_credential raises RuntimeError for failed calls."""
|
||||
mock_client.retrieve_credentials.side_effect = Exception(
|
||||
"API Quota Exhausted"
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Failed to retrieve credential for user 'user' on connector",
|
||||
) as exc_info:
|
||||
await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
# Assert that the original Exception is the chained cause!
|
||||
assert str(exc_info.value.__cause__) == "API Quota Exhausted"
|
||||
|
||||
|
||||
@patch.object(_iam_connector_credentials_provider.time, "time")
|
||||
async def test_get_auth_credential_raises_error_if_polling_times_out(
|
||||
mock_time,
|
||||
mock_operation,
|
||||
auth_scheme,
|
||||
context,
|
||||
provider,
|
||||
):
|
||||
"""Test get_auth_credential raises RuntimeError if polling times out."""
|
||||
|
||||
# Force the operation into the polling loop state
|
||||
meta_pb = RetrieveCredentialsMetadata.pb()()
|
||||
meta_pb.consent_pending.SetInParent()
|
||||
meta = RetrieveCredentialsMetadata.deserialize(meta_pb.SerializeToString())
|
||||
mock_operation.metadata.value = RetrieveCredentialsMetadata.serialize(meta)
|
||||
|
||||
# First call sets start_time=0.0, second call checks time > timeout
|
||||
# (20.0 > 10.0)
|
||||
mock_time.side_effect = [0.0, 20.0]
|
||||
|
||||
mock_metadata = Mock(spec=RetrieveCredentialsMetadata)
|
||||
mock_metadata.consent_pending = True
|
||||
mock_metadata.uri_consent_required = False
|
||||
mock_operation.done = True
|
||||
mock_operation.ClearField("error")
|
||||
mock_client = Mock(spec=Client)
|
||||
mock_client.retrieve_credentials.side_effect = Exception(
|
||||
"Timeout waiting for credentials."
|
||||
)
|
||||
provider._client = mock_client
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Failed to retrieve credential for user 'user' on connector",
|
||||
) as exc_info:
|
||||
await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
assert "Timeout waiting for credentials." in str(exc_info.value.__cause__)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Interactive Auth Flows (3-legged OAuth for User Consents)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
async def test_get_auth_credential_initiates_user_consent(
|
||||
mock_operation, auth_scheme, context, provider
|
||||
):
|
||||
# Explicitly set the mock behavior for this test
|
||||
expected_uri = "https://example.com/auth"
|
||||
expected_nonce = "sample-nonce-123"
|
||||
meta = RetrieveCredentialsMetadata({
|
||||
"uri_consent_required": {
|
||||
"authorization_uri": expected_uri,
|
||||
"consent_nonce": expected_nonce,
|
||||
}
|
||||
})
|
||||
mock_operation.metadata.value = RetrieveCredentialsMetadata.serialize(meta)
|
||||
mock_operation.done = False
|
||||
# Assert that there is no prior user consent completion event
|
||||
assert not context.session.events
|
||||
|
||||
credential = await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
assert credential is not None
|
||||
assert credential.auth_type == AuthCredentialTypes.OAUTH2
|
||||
assert credential.oauth2.auth_uri == expected_uri
|
||||
assert credential.oauth2.nonce == expected_nonce
|
||||
|
||||
|
||||
async def test_get_auth_credential_returns_fresh_auth_uri_for_repeated_requests(
|
||||
mock_client, mock_operation, auth_scheme, context, provider
|
||||
):
|
||||
"""Test that repeated calls fetch fresh auth URIs if consent is still pending."""
|
||||
# Arrange: Explicit initial URI
|
||||
initial_uri = "https://example.com/auth"
|
||||
initial_nonce = "initial-nonce-123"
|
||||
meta1 = RetrieveCredentialsMetadata({
|
||||
"uri_consent_required": {
|
||||
"authorization_uri": initial_uri,
|
||||
"consent_nonce": initial_nonce,
|
||||
}
|
||||
})
|
||||
mock_operation.metadata.value = RetrieveCredentialsMetadata.serialize(meta1)
|
||||
mock_operation.done = False
|
||||
|
||||
credential1 = await provider.get_auth_credential(auth_scheme, context)
|
||||
assert credential1.oauth2.auth_uri == initial_uri
|
||||
assert credential1.oauth2.nonce == initial_nonce
|
||||
|
||||
# Arrange: Explicit new URI for the second call
|
||||
fresh_auth_uri = "https://example.com/auth_new"
|
||||
fresh_nonce = "fresh-nonce-456"
|
||||
meta2 = RetrieveCredentialsMetadata({
|
||||
"uri_consent_required": {
|
||||
"authorization_uri": fresh_auth_uri,
|
||||
"consent_nonce": fresh_nonce,
|
||||
}
|
||||
})
|
||||
mock_operation.metadata.value = RetrieveCredentialsMetadata.serialize(meta2)
|
||||
|
||||
credential2 = await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
assert mock_client.retrieve_credentials.call_count == 2
|
||||
assert credential2.oauth2.auth_uri == fresh_auth_uri
|
||||
assert credential2.oauth2.nonce == fresh_nonce
|
||||
|
||||
|
||||
async def test_get_auth_credential_returns_token_if_consent_was_completed(
|
||||
mock_operation, auth_scheme, context, provider
|
||||
):
|
||||
# Setup mock credential for successful credential retrieval
|
||||
mock_credential = RetrieveCredentialsResponse(
|
||||
header="Authorization: Bearer", token="test-token"
|
||||
)
|
||||
mock_operation.response.value = RetrieveCredentialsResponse.serialize(
|
||||
mock_credential
|
||||
)
|
||||
|
||||
# Create mock events
|
||||
# 1. FunctionCall event for adk_request_credential
|
||||
function_call = Mock()
|
||||
function_call.id = "auth-req-1"
|
||||
function_call.name = REQUEST_EUC_FUNCTION_CALL_NAME
|
||||
function_call.args = AuthToolArguments(
|
||||
function_call_id="call-123",
|
||||
auth_config=Mock(spec=AuthConfig, auth_scheme=auth_scheme),
|
||||
).model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
event1 = Mock()
|
||||
event1.get_function_calls.return_value = [function_call]
|
||||
event1.get_function_responses.return_value = []
|
||||
|
||||
# 2. FunctionResponse event for adk_request_credential
|
||||
function_response = Mock()
|
||||
function_response.id = "auth-req-1"
|
||||
function_response.name = REQUEST_EUC_FUNCTION_CALL_NAME
|
||||
|
||||
event2 = Mock()
|
||||
event2.get_function_calls.return_value = []
|
||||
event2.get_function_responses.return_value = [function_response]
|
||||
|
||||
# Setup tool context and event history (order of events matters)
|
||||
context.session.events = [event1, event2]
|
||||
context.function_call_id = "call-123"
|
||||
|
||||
# Also set uri_consent_required to True-ish so it enters the check block
|
||||
meta = RetrieveCredentialsMetadata(
|
||||
uri_consent_required=RetrieveCredentialsMetadata.UriConsentRequired()
|
||||
)
|
||||
mock_operation.metadata.value = RetrieveCredentialsMetadata.serialize(meta)
|
||||
|
||||
# Execute
|
||||
auth_credential = await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
# Verify
|
||||
assert auth_credential is not None
|
||||
assert auth_credential.auth_type == AuthCredentialTypes.HTTP
|
||||
assert auth_credential.http.scheme == "Bearer"
|
||||
assert auth_credential.http.credentials.token == "test-token"
|
||||
|
||||
|
||||
async def test_get_auth_credential_raises_error_if_consent_canceled(
|
||||
mock_operation, auth_scheme, context, provider
|
||||
):
|
||||
function_call = Mock()
|
||||
function_call.id = "auth-req-1"
|
||||
function_call.name = REQUEST_EUC_FUNCTION_CALL_NAME
|
||||
function_call.args = AuthToolArguments(
|
||||
function_call_id="call-123",
|
||||
auth_config=Mock(spec=AuthConfig, auth_scheme=auth_scheme),
|
||||
).model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
event1 = Mock()
|
||||
event1.get_function_calls.return_value = [function_call]
|
||||
event1.get_function_responses.return_value = []
|
||||
|
||||
function_response = Mock()
|
||||
function_response.id = "auth-req-1"
|
||||
function_response.name = REQUEST_EUC_FUNCTION_CALL_NAME
|
||||
|
||||
event2 = Mock()
|
||||
event2.get_function_calls.return_value = []
|
||||
event2.get_function_responses.return_value = [function_response]
|
||||
|
||||
context.session.events = [event1, event2]
|
||||
context.function_call_id = "call-123"
|
||||
|
||||
meta = RetrieveCredentialsMetadata({
|
||||
"uri_consent_required": {
|
||||
"authorization_uri": "https://example.com/auth",
|
||||
"consent_nonce": "sample-nonce",
|
||||
}
|
||||
})
|
||||
mock_operation.metadata.value = RetrieveCredentialsMetadata.serialize(meta)
|
||||
mock_operation.done = False
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match="Failed to retrieve consent based credential."
|
||||
):
|
||||
await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
|
||||
async def test_get_auth_credential_handles_consent_pending_state_correctly(
|
||||
mock_client,
|
||||
auth_scheme,
|
||||
context,
|
||||
provider,
|
||||
):
|
||||
"""Test get_auth_credential enters the polling loop when consent is pending."""
|
||||
# 1. Setup the first retrieve_credentials call to return a pending Operation
|
||||
# containing consent_pending metadata.
|
||||
meta_pb = RetrieveCredentialsMetadata.pb()()
|
||||
meta_pb.consent_pending.SetInParent()
|
||||
|
||||
op_pending = Operation(done=False)
|
||||
op_pending.metadata.value = meta_pb.SerializeToString()
|
||||
|
||||
# 2. Setup the second retrieve_credentials call (the poll) to return success.
|
||||
op_success = Operation(done=True)
|
||||
resp = RetrieveCredentialsResponse(
|
||||
header="Authorization: Bearer", token="valid-token"
|
||||
)
|
||||
op_success.response.value = RetrieveCredentialsResponse.serialize(resp)
|
||||
|
||||
# Configure mock client to return pending first, then success
|
||||
mock_client.retrieve_credentials.side_effect = [
|
||||
Mock(operation=op_pending),
|
||||
Mock(operation=op_success),
|
||||
]
|
||||
|
||||
# 3. Call the provider
|
||||
credential = await provider.get_auth_credential(auth_scheme, context)
|
||||
|
||||
# 4. Verify that it polled and successfully returned the token
|
||||
assert credential is not None
|
||||
assert credential.http.credentials.token == "valid-token"
|
||||
|
||||
# Verify that retrieve_credentials was called twice (initial + 1 poll)
|
||||
assert mock_client.retrieve_credentials.call_count == 2
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,926 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.openapi.models import OAuth2
|
||||
from google.adk.a2a import _compat
|
||||
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
|
||||
from google.adk.auth.auth_credential import AuthCredential
|
||||
from google.adk.auth.auth_credential import OAuth2Auth
|
||||
from google.adk.integrations.agent_registry import AgentRegistry
|
||||
from google.adk.integrations.agent_registry.agent_registry import _ProtocolType
|
||||
from google.adk.telemetry.tracing import GCP_MCP_SERVER_DESTINATION_ID
|
||||
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset
|
||||
import httpx
|
||||
from mcp import ClientSession
|
||||
from mcp.types import ListToolsResult
|
||||
from mcp.types import Tool
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
def _assert_preferred_transport(agent_card, expected):
|
||||
"""Assert an agent card's preferred transport, version-agnostically."""
|
||||
# 0.3.x exposes a top-level `preferred_transport` field; 1.x dropped it in
|
||||
# favor of `supported_interfaces[*].protocol_binding`. `expected` is a
|
||||
# `_compat.TP_*` constant; its binding value is the wire string on both SDKs.
|
||||
if not _compat.IS_A2A_V1:
|
||||
assert agent_card.preferred_transport == expected
|
||||
return
|
||||
bindings = [
|
||||
iface.protocol_binding for iface in agent_card.supported_interfaces or []
|
||||
]
|
||||
assert getattr(expected, "value", expected) in bindings
|
||||
|
||||
|
||||
def _assert_protocol_version(agent_card, expected):
|
||||
"""Assert an agent card's protocol version, version-agnostically."""
|
||||
# 0.3.x exposes a top-level `protocol_version` field; 1.x moved it onto each
|
||||
# `supported_interfaces[*].protocol_version`.
|
||||
if not _compat.IS_A2A_V1:
|
||||
assert agent_card.protocol_version == expected
|
||||
return
|
||||
versions = [
|
||||
iface.protocol_version for iface in agent_card.supported_interfaces or []
|
||||
]
|
||||
assert expected in versions
|
||||
|
||||
|
||||
class TestAgentRegistry:
|
||||
|
||||
@pytest.fixture
|
||||
def registry(self):
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.quota_project_id = None
|
||||
with (
|
||||
patch("google.auth.default", return_value=(mock_creds, "project-id")),
|
||||
patch(
|
||||
"google.auth.transport.requests.AuthorizedSession",
|
||||
autospec=True,
|
||||
) as mock_session_class,
|
||||
):
|
||||
registry = AgentRegistry(project_id="test-project", location="global")
|
||||
return registry
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"google.adk.tools.mcp_tool.mcp_session_manager.MCPSessionManager.create_session",
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
async def test_get_mcp_toolset_adds_destination_id(
|
||||
self, mock_create_session, registry
|
||||
):
|
||||
"""Test that tools from get_mcp_toolset have the destination ID."""
|
||||
# Arrange
|
||||
mcp_server_name = "test-mcp-server"
|
||||
mock_api_response = MagicMock()
|
||||
mock_api_response.json.return_value = {
|
||||
"displayName": "TestPrefix",
|
||||
"mcpServerId": (
|
||||
"urn:mcp:googleapis.com:projects:1234:locations:global:bigquery"
|
||||
),
|
||||
"interfaces": [{
|
||||
"url": "https://mcp.com",
|
||||
"protocolBinding": "JSONRPC",
|
||||
}],
|
||||
}
|
||||
registry._session.get.return_value = mock_api_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
mock_session = AsyncMock(spec=ClientSession)
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
# Mock the tools returned by list_tools
|
||||
mock_session.list_tools.return_value = ListToolsResult(
|
||||
tools=[
|
||||
Tool(
|
||||
name="tool1",
|
||||
description="d1",
|
||||
inputs={},
|
||||
outputs={},
|
||||
inputSchema={},
|
||||
),
|
||||
Tool(
|
||||
name="tool2",
|
||||
description="d2",
|
||||
inputs={},
|
||||
outputs={},
|
||||
inputSchema={},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Act
|
||||
toolset = registry.get_mcp_toolset(mcp_server_name)
|
||||
tools = await toolset.get_tools()
|
||||
|
||||
# Assert
|
||||
assert isinstance(toolset, McpToolset)
|
||||
mock_session.list_tools.assert_called_once_with()
|
||||
assert len(tools) == 2
|
||||
for tool in tools:
|
||||
assert tool.custom_metadata is not None
|
||||
assert (
|
||||
tool.custom_metadata.get(GCP_MCP_SERVER_DESTINATION_ID)
|
||||
== "urn:mcp:googleapis.com:projects:1234:locations:global:bigquery"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"google.adk.tools.mcp_tool.mcp_session_manager.MCPSessionManager.create_session",
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
async def test_get_mcp_toolset_handles_missing_destination_id(
|
||||
self, mock_create_session, registry
|
||||
):
|
||||
"""Test get_mcp_toolset when the destination ID is missing."""
|
||||
# Arrange
|
||||
mcp_server_name = "test-mcp-server"
|
||||
mock_api_response = MagicMock()
|
||||
mock_api_response.json.return_value = {
|
||||
"displayName": "TestPrefix",
|
||||
# "mcpServerId" is intentionally omitted
|
||||
"interfaces": [{
|
||||
"url": "https://mcp.com",
|
||||
"protocolBinding": "JSONRPC",
|
||||
}],
|
||||
}
|
||||
registry._session.get.return_value = mock_api_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
mock_session = AsyncMock(spec=ClientSession)
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
# Mock the tools returned by list_tools
|
||||
mock_session.list_tools.return_value = ListToolsResult(
|
||||
tools=[
|
||||
Tool(
|
||||
name="tool1",
|
||||
description="d1",
|
||||
inputs={},
|
||||
outputs={},
|
||||
inputSchema={},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Act
|
||||
toolset = registry.get_mcp_toolset(mcp_server_name)
|
||||
tools = await toolset.get_tools()
|
||||
|
||||
# Assert
|
||||
assert isinstance(toolset, McpToolset)
|
||||
mock_session.list_tools.assert_called_once_with()
|
||||
assert len(tools) == 1
|
||||
for tool in tools:
|
||||
# The custom_metadata shouldn't have been added
|
||||
assert tool.custom_metadata is None
|
||||
|
||||
def test_init_raises_value_error_if_params_missing(self):
|
||||
with pytest.raises(
|
||||
ValueError, match="project_id and location must be provided"
|
||||
):
|
||||
AgentRegistry(project_id=None, location=None)
|
||||
|
||||
def test_get_connection_uri_mcp_interfaces_top_level(self, registry):
|
||||
resource_details = {
|
||||
"interfaces": [
|
||||
{"url": "https://mcp-v1main.com", "protocolBinding": "JSONRPC"}
|
||||
]
|
||||
}
|
||||
uri, version, binding = registry._get_connection_uri(
|
||||
resource_details, protocol_binding=_compat.TP_JSONRPC
|
||||
)
|
||||
assert uri == "https://mcp-v1main.com"
|
||||
assert version is None
|
||||
assert binding == "JSONRPC"
|
||||
|
||||
def test_get_connection_uri_agent_nested_protocols(self, registry):
|
||||
resource_details = {
|
||||
"protocols": [{
|
||||
"type": _ProtocolType.A2A_AGENT,
|
||||
"interfaces": [{
|
||||
"url": "https://my-agent.com",
|
||||
"protocolBinding": "JSONRPC",
|
||||
}],
|
||||
}]
|
||||
}
|
||||
uri, version, binding = registry._get_connection_uri(
|
||||
resource_details, protocol_type=_ProtocolType.A2A_AGENT
|
||||
)
|
||||
assert uri == "https://my-agent.com"
|
||||
assert version is None
|
||||
assert binding == _compat.TP_JSONRPC
|
||||
|
||||
def test_get_connection_uri_filtering(self, registry):
|
||||
resource_details = {
|
||||
"protocols": [
|
||||
{
|
||||
"type": "CUSTOM",
|
||||
"interfaces": [{"url": "https://custom.com"}],
|
||||
},
|
||||
{
|
||||
"type": _ProtocolType.A2A_AGENT,
|
||||
"interfaces": [{
|
||||
"url": "https://my-agent.com",
|
||||
"protocolBinding": "HTTP_JSON",
|
||||
}],
|
||||
},
|
||||
]
|
||||
}
|
||||
# Filter by type
|
||||
uri, version, binding = registry._get_connection_uri(
|
||||
resource_details, protocol_type=_ProtocolType.A2A_AGENT
|
||||
)
|
||||
assert uri == "https://my-agent.com"
|
||||
assert version is None
|
||||
assert binding == _compat.TP_HTTP_JSON
|
||||
|
||||
# Filter by binding
|
||||
uri, version, binding = registry._get_connection_uri(
|
||||
resource_details, protocol_binding=_compat.TP_HTTP_JSON
|
||||
)
|
||||
assert uri == "https://my-agent.com"
|
||||
assert version is None
|
||||
assert binding == _compat.TP_HTTP_JSON
|
||||
|
||||
# No match
|
||||
uri, version, binding = registry._get_connection_uri(
|
||||
resource_details,
|
||||
protocol_type=_ProtocolType.A2A_AGENT,
|
||||
protocol_binding=_compat.TP_JSONRPC,
|
||||
)
|
||||
assert uri is None
|
||||
assert version is None
|
||||
assert binding is None
|
||||
|
||||
def test_get_connection_uri_returns_none_if_no_interfaces(self, registry):
|
||||
resource_details = {}
|
||||
uri, version, binding = registry._get_connection_uri(resource_details)
|
||||
assert uri is None
|
||||
assert version is None
|
||||
assert binding is None
|
||||
|
||||
def test_get_connection_uri_returns_none_if_no_url_in_interfaces(
|
||||
self, registry
|
||||
):
|
||||
resource_details = {"interfaces": [{"protocolBinding": "HTTP"}]}
|
||||
uri, version, binding = registry._get_connection_uri(resource_details)
|
||||
assert uri is None
|
||||
assert version is None
|
||||
assert binding is None
|
||||
|
||||
def test_list_agents(self, registry):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"agents": []}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.get.return_value = mock_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
agents = registry.list_agents()
|
||||
assert agents == {"agents": []}
|
||||
|
||||
def test_search_agents(self, registry):
|
||||
"""Tests search_agents API call."""
|
||||
# pylint: disable=protected-access
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"agents": [{"name": "agent-1"}]}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.post.return_value = mock_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
agents = registry.search_agents(
|
||||
search_string="test-agent",
|
||||
search_type="KEYWORD",
|
||||
filter_str="display_name:test",
|
||||
order_by="name",
|
||||
page_size=10,
|
||||
page_token="next-token",
|
||||
)
|
||||
assert agents == {"agents": [{"name": "agent-1"}]}
|
||||
registry._session.post.assert_called_once_with(
|
||||
f"{registry._base_url}/projects/test-project/locations/global/agents:search",
|
||||
headers={"x-goog-user-project": "test-project"},
|
||||
json={
|
||||
"searchString": "test-agent",
|
||||
"searchType": "KEYWORD",
|
||||
"filter": "display_name:test",
|
||||
"orderBy": "name",
|
||||
"pageSize": 10,
|
||||
"pageToken": "next-token",
|
||||
},
|
||||
)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
def test_search_mcp_servers(self, registry):
|
||||
"""Tests search_mcp_servers API call."""
|
||||
# pylint: disable=protected-access
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"mcpServers": [{"name": "mcp-1"}]}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.post.return_value = mock_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
mcp_servers = registry.search_mcp_servers(
|
||||
search_string="test-mcp",
|
||||
search_type="KEYWORD",
|
||||
filter_str="display_name:test",
|
||||
order_by="name",
|
||||
page_size=10,
|
||||
page_token="next-token",
|
||||
)
|
||||
assert mcp_servers == {"mcpServers": [{"name": "mcp-1"}]}
|
||||
registry._session.post.assert_called_once_with(
|
||||
f"{registry._base_url}/projects/test-project/locations/global/mcpServers:search",
|
||||
headers={"x-goog-user-project": "test-project"},
|
||||
json={
|
||||
"searchString": "test-mcp",
|
||||
"searchType": "KEYWORD",
|
||||
"filter": "display_name:test",
|
||||
"orderBy": "name",
|
||||
"pageSize": 10,
|
||||
"pageToken": "next-token",
|
||||
},
|
||||
)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
def test_get_mcp_server(self, registry):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"name": "test-mcp"}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.get.return_value = mock_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
server = registry.get_mcp_server("test-mcp")
|
||||
assert server == {"name": "test-mcp"}
|
||||
|
||||
def test_list_endpoints(self, registry):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"endpoints": []}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.get.return_value = mock_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
endpoints = registry.list_endpoints()
|
||||
assert endpoints == {"endpoints": []}
|
||||
|
||||
def test_get_endpoint(self, registry):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"name": "test-endpoint"}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.get.return_value = mock_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
server = registry.get_endpoint("test-endpoint")
|
||||
assert server == {"name": "test-endpoint"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, expected_auth, use_custom_provider",
|
||||
[
|
||||
("https://mcp.com", False, False),
|
||||
("https://mcp.googleapis.com/v1", True, False),
|
||||
("https://example.com/googleapis/v1", False, False),
|
||||
("https://mcp.googleapis.com/v1", True, True),
|
||||
],
|
||||
)
|
||||
def test_get_mcp_toolset_auth_headers(
|
||||
self, registry, url, expected_auth, use_custom_provider
|
||||
):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"displayName": "TestPrefix",
|
||||
"interfaces": [{
|
||||
"url": url,
|
||||
"protocolBinding": "JSONRPC",
|
||||
}],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.get.return_value = mock_response
|
||||
|
||||
if use_custom_provider:
|
||||
custom_header_provider = lambda context: {
|
||||
"Authorization": "Bearer custom_token"
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"google.auth.default", return_value=(MagicMock(), "project-id")
|
||||
),
|
||||
patch("google.auth.transport.requests.AuthorizedSession"),
|
||||
):
|
||||
registry = AgentRegistry(
|
||||
project_id="test-project",
|
||||
location="global",
|
||||
header_provider=custom_header_provider,
|
||||
)
|
||||
registry._session.get.return_value = mock_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
toolset = registry.get_mcp_toolset("test-mcp")
|
||||
assert isinstance(toolset, McpToolset)
|
||||
assert toolset.tool_name_prefix == "TestPrefix"
|
||||
assert toolset._connection_params.headers is None
|
||||
headers = toolset._header_provider(MagicMock())
|
||||
|
||||
if use_custom_provider:
|
||||
assert headers.get("Authorization") == "Bearer custom_token"
|
||||
elif expected_auth:
|
||||
assert headers.get("Authorization") == "Bearer token"
|
||||
else:
|
||||
assert "Authorization" not in headers
|
||||
|
||||
def test_get_mcp_toolset_with_auth(self, registry):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"displayName": "TestPrefix",
|
||||
"interfaces": [{
|
||||
"url": "https://mcp.com",
|
||||
"protocolBinding": "JSONRPC",
|
||||
}],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.get.return_value = mock_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
auth_scheme = OAuth2(flows={})
|
||||
auth_credential = AuthCredential(
|
||||
auth_type="oauth2",
|
||||
oauth2=OAuth2Auth(client_id="test_id", client_secret="test_secret"),
|
||||
)
|
||||
|
||||
toolset = registry.get_mcp_toolset(
|
||||
"test-mcp", auth_scheme=auth_scheme, auth_credential=auth_credential
|
||||
)
|
||||
assert isinstance(toolset, McpToolset)
|
||||
auth_config = toolset.get_auth_config()
|
||||
assert auth_config is not None
|
||||
assert auth_config.auth_scheme == auth_scheme
|
||||
assert auth_config.raw_auth_credential == auth_credential
|
||||
|
||||
def test_get_mcp_toolset_with_auth_blocks_gcp_headers(self, registry):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"displayName": "TestPrefix",
|
||||
"interfaces": [{
|
||||
"url": "https://mcp.googleapis.com/v1",
|
||||
"protocolBinding": "JSONRPC",
|
||||
}],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.get.return_value = mock_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
auth_scheme = OAuth2(flows={})
|
||||
auth_credential = AuthCredential(
|
||||
auth_type="oauth2",
|
||||
oauth2=OAuth2Auth(client_id="test_id", client_secret="test_secret"),
|
||||
)
|
||||
|
||||
toolset = registry.get_mcp_toolset(
|
||||
"test-mcp", auth_scheme=auth_scheme, auth_credential=auth_credential
|
||||
)
|
||||
assert isinstance(toolset, McpToolset)
|
||||
|
||||
headers = toolset._header_provider(MagicMock())
|
||||
assert "Authorization" not in headers
|
||||
|
||||
def test_get_remote_a2a_agent(self, registry):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"displayName": "TestAgent",
|
||||
"description": "Test Desc",
|
||||
"version": "1.0",
|
||||
"protocols": [{
|
||||
"type": _ProtocolType.A2A_AGENT,
|
||||
"protocolVersion": "0.4.0",
|
||||
"interfaces": [{
|
||||
"url": "https://my-agent.com",
|
||||
"protocolBinding": "HTTP_JSON",
|
||||
}],
|
||||
}],
|
||||
"skills": [{"id": "s1", "name": "Skill 1", "description": "Desc 1"}],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.get.return_value = mock_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
agent = registry.get_remote_a2a_agent("test-agent")
|
||||
assert isinstance(agent, RemoteA2aAgent)
|
||||
assert agent.name == "TestAgent"
|
||||
assert agent.description == "Test Desc"
|
||||
assert _compat.agent_card_url(agent._agent_card) == "https://my-agent.com"
|
||||
assert agent._agent_card.version == "1.0"
|
||||
assert len(agent._agent_card.skills) == 1
|
||||
assert agent._agent_card.skills[0].name == "Skill 1"
|
||||
_assert_preferred_transport(agent._agent_card, _compat.TP_HTTP_JSON)
|
||||
_assert_protocol_version(agent._agent_card, "0.4.0")
|
||||
|
||||
def test_get_remote_a2a_agent_defaults(self, registry):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"displayName": "TestAgent",
|
||||
"description": "Test Desc",
|
||||
"version": "1.0",
|
||||
"protocols": [{
|
||||
"type": _ProtocolType.A2A_AGENT,
|
||||
"interfaces": [{
|
||||
"url": "https://my-agent.com",
|
||||
}],
|
||||
}],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.get.return_value = mock_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
agent = registry.get_remote_a2a_agent("test-agent")
|
||||
assert isinstance(agent, RemoteA2aAgent)
|
||||
_assert_preferred_transport(agent._agent_card, _compat.TP_HTTP_JSON)
|
||||
# When the interface omits an explicit protocol version, each SDK applies
|
||||
# its own default ("0.3.0" on 0.3.x, "1.0" on 1.x).
|
||||
_assert_protocol_version(
|
||||
agent._agent_card, "1.0" if _compat.IS_A2A_V1 else "0.3.0"
|
||||
)
|
||||
|
||||
def test_get_remote_a2a_agent_with_card(self, registry):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"name": "projects/p/locations/l/agents/a",
|
||||
"card": {
|
||||
"type": "A2A_AGENT_CARD",
|
||||
"content": {
|
||||
"name": "CardName",
|
||||
"description": "CardDesc",
|
||||
"version": "2.0",
|
||||
"url": "https://card-url.com",
|
||||
"skills": [{
|
||||
"id": "s1",
|
||||
"name": "S1",
|
||||
"description": "D1",
|
||||
"tags": ["t1"],
|
||||
}],
|
||||
"capabilities": {"streaming": True, "polling": False},
|
||||
"defaultInputModes": ["text"],
|
||||
"defaultOutputModes": ["text"],
|
||||
},
|
||||
},
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.get.return_value = mock_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
agent = registry.get_remote_a2a_agent("test-agent")
|
||||
assert isinstance(agent, RemoteA2aAgent)
|
||||
assert agent.name == "CardName"
|
||||
assert agent.description == "CardDesc"
|
||||
assert agent._agent_card.version == "2.0"
|
||||
assert _compat.agent_card_url(agent._agent_card) == "https://card-url.com"
|
||||
assert agent._agent_card.capabilities.streaming is True
|
||||
assert len(agent._agent_card.skills) == 1
|
||||
assert agent._agent_card.skills[0].name == "S1"
|
||||
|
||||
def test_get_remote_a2a_agent_with_httpx_client(self, registry):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"displayName": "TestAgent",
|
||||
"description": "Test Desc",
|
||||
"version": "1.0",
|
||||
"protocols": [{
|
||||
"type": _ProtocolType.A2A_AGENT,
|
||||
"interfaces": [{
|
||||
"url": "https://my-agent.com",
|
||||
}],
|
||||
}],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.get.return_value = mock_response
|
||||
|
||||
custom_client = httpx.AsyncClient()
|
||||
agent = registry.get_remote_a2a_agent(
|
||||
"test-agent", httpx_client=custom_client
|
||||
)
|
||||
assert agent._httpx_client is custom_client
|
||||
|
||||
def test_get_remote_a2a_agent_configures_transports(self, registry):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"displayName": "TestAgent",
|
||||
"protocols": [{
|
||||
"type": _ProtocolType.A2A_AGENT,
|
||||
"interfaces": [{
|
||||
"url": "https://my-agent.com",
|
||||
"protocolBinding": _compat.TP_JSONRPC,
|
||||
}],
|
||||
}],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
registry._session.get.return_value = mock_response
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
agent = registry.get_remote_a2a_agent("test-agent")
|
||||
_assert_preferred_transport(agent._agent_card, _compat.TP_JSONRPC)
|
||||
|
||||
def test_get_auth_headers(self, registry):
|
||||
registry._credentials.token = "fake-token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
headers = registry._get_auth_headers()
|
||||
assert headers["Authorization"] == "Bearer fake-token"
|
||||
assert "x-goog-user-project" not in headers
|
||||
|
||||
def test_make_request_raises_http_status_error(self, registry):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.text = "Internal Server Error"
|
||||
error = requests.exceptions.HTTPError(
|
||||
"Error", request=MagicMock(), response=mock_response
|
||||
)
|
||||
registry._session.get.side_effect = error
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match="API request failed with status 500"
|
||||
):
|
||||
registry._make_request("test-path")
|
||||
|
||||
def test_make_request_raises_request_error(self, registry):
|
||||
error = requests.exceptions.RequestException(
|
||||
"Connection failed", request=MagicMock()
|
||||
)
|
||||
registry._session.get.side_effect = error
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match=r"API request failed \(network error\)"
|
||||
):
|
||||
registry._make_request("test-path")
|
||||
|
||||
def test_make_request_raises_generic_exception(self, registry):
|
||||
registry._session.get.side_effect = Exception("Generic error")
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
with pytest.raises(RuntimeError, match="API request failed: Generic error"):
|
||||
registry._make_request("test-path")
|
||||
|
||||
@patch.object(AgentRegistry, "get_endpoint")
|
||||
def test_get_model_name_starts_with_projects(
|
||||
self, mock_get_endpoint, registry
|
||||
):
|
||||
mock_get_endpoint.return_value = {
|
||||
"interfaces": [{"url": "projects/p1/locations/l1/models/m1"}]
|
||||
}
|
||||
model_name = registry.get_model_name("test-endpoint")
|
||||
assert model_name == "projects/p1/locations/l1/models/m1"
|
||||
|
||||
@patch.object(AgentRegistry, "get_endpoint")
|
||||
def test_get_model_name_contains_projects(self, mock_get_endpoint, registry):
|
||||
mock_get_endpoint.return_value = {
|
||||
"interfaces": [{
|
||||
"url": (
|
||||
"https://vertexai.googleapis.com/v1/projects/p1/locations/l1/models/m1"
|
||||
)
|
||||
}]
|
||||
}
|
||||
model_name = registry.get_model_name("test-endpoint")
|
||||
assert model_name == "projects/p1/locations/l1/models/m1"
|
||||
|
||||
@patch.object(AgentRegistry, "get_endpoint")
|
||||
def test_get_model_name_strips_suffix(self, mock_get_endpoint, registry):
|
||||
mock_get_endpoint.return_value = {
|
||||
"interfaces": [{"url": "projects/p1/locations/l1/models/m1:predict"}]
|
||||
}
|
||||
model_name = registry.get_model_name("test-endpoint")
|
||||
assert model_name == "projects/p1/locations/l1/models/m1"
|
||||
|
||||
@patch.object(AgentRegistry, "get_endpoint")
|
||||
def test_get_model_name_raises_value_error_if_no_uri(
|
||||
self, mock_get_endpoint, registry
|
||||
):
|
||||
mock_get_endpoint.return_value = {}
|
||||
with pytest.raises(ValueError, match="Connection URI not found"):
|
||||
registry.get_model_name("test-endpoint")
|
||||
|
||||
@patch.object(AgentRegistry, "_make_request")
|
||||
def test_get_mcp_toolset_with_binding(self, mock_make_request, registry):
|
||||
def side_effect(*args, **kwargs):
|
||||
if args[0] == "test-mcp":
|
||||
return {
|
||||
"displayName": "TestPrefix",
|
||||
"mcpServerId": "server-456",
|
||||
"interfaces": [{
|
||||
"url": "https://mcp.com",
|
||||
"protocolBinding": "JSONRPC",
|
||||
}],
|
||||
}
|
||||
if args[0] == "bindings":
|
||||
return {
|
||||
"bindings": [{
|
||||
"target": {
|
||||
"identifier": (
|
||||
"urn:mcp:projects-123:projects:123:locations:l:mcpServers:server-456"
|
||||
)
|
||||
},
|
||||
"authProviderBinding": {
|
||||
"authProvider": (
|
||||
"projects/123/locations/l/authProviders/ap-789"
|
||||
)
|
||||
},
|
||||
}]
|
||||
}
|
||||
return {}
|
||||
|
||||
mock_make_request.side_effect = side_effect
|
||||
|
||||
registry._credentials.token = "token"
|
||||
registry._credentials.refresh = MagicMock()
|
||||
|
||||
toolset = registry.get_mcp_toolset(
|
||||
"test-mcp", continue_uri="https://override.com/continue"
|
||||
)
|
||||
assert isinstance(toolset, McpToolset)
|
||||
assert toolset._auth_scheme is not None
|
||||
assert (
|
||||
toolset._auth_scheme.name
|
||||
== "projects/123/locations/l/authProviders/ap-789"
|
||||
)
|
||||
assert toolset._auth_scheme.continue_uri == "https://override.com/continue"
|
||||
|
||||
|
||||
class TestAgentRegistryMtls:
|
||||
|
||||
@pytest.fixture
|
||||
def registry(self):
|
||||
with (
|
||||
patch(
|
||||
"google.auth.default", return_value=(MagicMock(), "test-project")
|
||||
),
|
||||
patch("google.auth.transport.requests.AuthorizedSession"),
|
||||
patch(
|
||||
"google.adk.integrations.agent_registry.agent_registry._use_client_cert_effective",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
return AgentRegistry(project_id="test-project", location="global")
|
||||
|
||||
@patch(
|
||||
"google.auth.transport.mtls.has_default_client_cert_source",
|
||||
return_value=False,
|
||||
)
|
||||
def test_make_request_uses_authorized_session_no_mtls(
|
||||
self, mock_has_cert, registry
|
||||
):
|
||||
mock_session = registry._session
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"key": "value"}
|
||||
mock_session.get.return_value = mock_response
|
||||
|
||||
result = registry._make_request("test-path")
|
||||
|
||||
mock_session.get.assert_called_once()
|
||||
assert mock_session.configure_mtls_channel.call_count == 0
|
||||
assert result == {"key": "value"}
|
||||
|
||||
@patch(
|
||||
"google.auth.transport.mtls.has_default_client_cert_source",
|
||||
return_value=True,
|
||||
)
|
||||
@patch("google.auth.transport.mtls.default_client_cert_source")
|
||||
@patch.dict(os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "true"})
|
||||
def test_make_request_configures_mtls(self, mock_cert_source, registry):
|
||||
mock_cert_source.return_value = lambda: (b"cert", b"key")
|
||||
with (
|
||||
patch(
|
||||
"google.auth.default", return_value=(MagicMock(), "test-project")
|
||||
),
|
||||
patch(
|
||||
"google.adk.integrations.agent_registry.agent_registry._use_client_cert_effective",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"google.auth.transport.requests.AuthorizedSession"
|
||||
) as mock_session_class,
|
||||
):
|
||||
# Instantiate inside the test after enabling mTLS patches
|
||||
registry = AgentRegistry(project_id="test-project", location="global")
|
||||
mock_session = registry._session
|
||||
|
||||
# Mock successful response
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"key": "value"}
|
||||
mock_session.get.return_value = mock_response
|
||||
|
||||
registry._make_request("test-path")
|
||||
|
||||
# Verify mTLS configuration and endpoint
|
||||
mock_session.configure_mtls_channel.assert_called_once()
|
||||
args, kwargs = mock_session.get.call_args
|
||||
assert "agentregistry.mtls.googleapis.com" in args[0]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env_val, has_cert, expected",
|
||||
[
|
||||
("true", True, True),
|
||||
("true", False, True),
|
||||
("false", True, False),
|
||||
("false", False, False),
|
||||
],
|
||||
)
|
||||
def test_use_client_cert_effective(
|
||||
self, env_val, has_cert, expected, registry
|
||||
):
|
||||
with patch.dict(os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": env_val}):
|
||||
with patch(
|
||||
"google.auth.transport.mtls.has_default_client_cert_source",
|
||||
return_value=has_cert,
|
||||
):
|
||||
from google.adk.integrations.agent_registry.agent_registry import _use_client_cert_effective
|
||||
|
||||
assert _use_client_cert_effective() == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"use_mtls_env, client_cert_source, expected_domain",
|
||||
[
|
||||
# Auto mode (default)
|
||||
(None, None, "agentregistry.googleapis.com"),
|
||||
(None, lambda: True, "agentregistry.mtls.googleapis.com"),
|
||||
# Always mode
|
||||
("always", None, "agentregistry.mtls.googleapis.com"),
|
||||
("always", lambda: True, "agentregistry.mtls.googleapis.com"),
|
||||
# Never mode
|
||||
("never", None, "agentregistry.googleapis.com"),
|
||||
("never", lambda: True, "agentregistry.googleapis.com"),
|
||||
],
|
||||
)
|
||||
def test_get_agent_registry_base_url(
|
||||
self, use_mtls_env, client_cert_source, expected_domain, registry
|
||||
):
|
||||
from google.adk.integrations.agent_registry.agent_registry import _get_agent_registry_base_url
|
||||
|
||||
env_patch = {}
|
||||
if use_mtls_env is not None:
|
||||
env_patch["GOOGLE_API_USE_MTLS_ENDPOINT"] = use_mtls_env
|
||||
else:
|
||||
# Ensure any ambient env var doesn't leak into the test
|
||||
env_patch = {"GOOGLE_API_USE_MTLS_ENDPOINT": "auto"}
|
||||
|
||||
with patch.dict(os.environ, env_patch):
|
||||
assert expected_domain in _get_agent_registry_base_url(client_cert_source)
|
||||
|
||||
def test_make_request_error_handling(self, registry):
|
||||
mock_session = registry._session
|
||||
mock_session.get.side_effect = Exception("Connection error")
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match="API request failed: Connection error"
|
||||
):
|
||||
registry._make_request("test-path")
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,403 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.integrations import api_registry
|
||||
from google.adk.integrations.api_registry import ApiRegistry
|
||||
from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams
|
||||
import requests
|
||||
|
||||
MOCK_MCP_SERVERS_LIST = {
|
||||
"mcpServers": [
|
||||
{
|
||||
"name": "test-mcp-server-1",
|
||||
"urls": ["mcp.server1.com"],
|
||||
},
|
||||
{
|
||||
"name": "test-mcp-server-2",
|
||||
"urls": ["mcp.server2.com"],
|
||||
},
|
||||
{
|
||||
"name": "test-mcp-server-no-url",
|
||||
},
|
||||
{
|
||||
"name": "test-mcp-server-http",
|
||||
"urls": ["http://mcp.server_http.com"],
|
||||
},
|
||||
{
|
||||
"name": "test-mcp-server-https",
|
||||
"urls": ["https://mcp.server_https.com"],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class TestApiRegistry(unittest.IsolatedAsyncioTestCase):
|
||||
"""Unit tests for ApiRegistry."""
|
||||
|
||||
def setUp(self):
|
||||
self.project_id = "test-project"
|
||||
self.location = "global"
|
||||
self.mock_credentials = MagicMock()
|
||||
self.mock_credentials.token = "mock_token"
|
||||
self.mock_credentials.refresh = MagicMock()
|
||||
self.mock_credentials.quota_project_id = None
|
||||
mock_auth_patcher = patch(
|
||||
"google.auth.default",
|
||||
return_value=(self.mock_credentials, None),
|
||||
autospec=True,
|
||||
)
|
||||
mock_auth_patcher.start()
|
||||
self.addCleanup(mock_auth_patcher.stop)
|
||||
|
||||
mock_session_patcher = patch(
|
||||
"google.auth.transport.requests.AuthorizedSession",
|
||||
autospec=True,
|
||||
)
|
||||
self.mock_session_class = mock_session_patcher.start()
|
||||
self.mock_session = self.mock_session_class.return_value
|
||||
self.mock_session.__enter__.return_value = self.mock_session
|
||||
self.addCleanup(mock_session_patcher.stop)
|
||||
|
||||
mock_use_cert_patcher = patch(
|
||||
"google.adk.integrations.api_registry.api_registry._mtls_utils.use_client_cert_effective",
|
||||
return_value=False,
|
||||
)
|
||||
mock_use_cert_patcher.start()
|
||||
self.addCleanup(mock_use_cert_patcher.stop)
|
||||
|
||||
def test_init_success(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json = MagicMock(return_value=MOCK_MCP_SERVERS_LIST)
|
||||
self.mock_session.get.return_value = mock_response
|
||||
|
||||
api_registry_instance = ApiRegistry(
|
||||
api_registry_project_id=self.project_id, location=self.location
|
||||
)
|
||||
|
||||
self.assertEqual(len(api_registry_instance._mcp_servers), 5)
|
||||
self.assertIn("test-mcp-server-1", api_registry_instance._mcp_servers)
|
||||
self.assertIn("test-mcp-server-2", api_registry_instance._mcp_servers)
|
||||
self.assertIn("test-mcp-server-no-url", api_registry_instance._mcp_servers)
|
||||
self.assertIn("test-mcp-server-http", api_registry_instance._mcp_servers)
|
||||
self.assertIn("test-mcp-server-https", api_registry_instance._mcp_servers)
|
||||
self.mock_session.get.assert_called_once_with(
|
||||
f"https://cloudapiregistry.googleapis.com/v1beta/projects/{self.project_id}/locations/{self.location}/mcpServers",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
params={"filter": "enabled=false"},
|
||||
)
|
||||
|
||||
def test_init_with_quota_project_id_success(self):
|
||||
self.mock_credentials.quota_project_id = "quota-project"
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = MOCK_MCP_SERVERS_LIST
|
||||
self.mock_session.get.return_value = mock_response
|
||||
|
||||
api_registry_instance = ApiRegistry(
|
||||
api_registry_project_id=self.project_id, location=self.location
|
||||
)
|
||||
|
||||
self.assertEqual(len(api_registry_instance._mcp_servers), 5)
|
||||
self.mock_session.get.assert_called_once_with(
|
||||
f"https://cloudapiregistry.googleapis.com/v1beta/projects/{self.project_id}/locations/{self.location}/mcpServers",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-user-project": "quota-project",
|
||||
},
|
||||
params={"filter": "enabled=false"},
|
||||
)
|
||||
|
||||
def test_init_with_pagination_success(self):
|
||||
mock_response1 = MagicMock()
|
||||
mock_response1.json.return_value = {
|
||||
"mcpServers": [
|
||||
{
|
||||
"name": "test-mcp-server-1",
|
||||
"urls": ["mcp.server1.com"],
|
||||
},
|
||||
{
|
||||
"name": "test-mcp-server-2",
|
||||
"urls": ["mcp.server2.com"],
|
||||
},
|
||||
],
|
||||
"nextPageToken": "next_page_token",
|
||||
}
|
||||
mock_response2 = MagicMock()
|
||||
mock_response2.json.return_value = {
|
||||
"mcpServers": [
|
||||
{
|
||||
"name": "test-mcp-server-no-url",
|
||||
},
|
||||
{
|
||||
"name": "test-mcp-server-http",
|
||||
"urls": ["http://mcp.server_http.com"],
|
||||
},
|
||||
{
|
||||
"name": "test-mcp-server-https",
|
||||
"urls": ["https://mcp.server_https.com"],
|
||||
},
|
||||
]
|
||||
}
|
||||
self.mock_session.get.side_effect = [mock_response1, mock_response2]
|
||||
|
||||
api_registry_instance = ApiRegistry(
|
||||
api_registry_project_id=self.project_id, location=self.location
|
||||
)
|
||||
|
||||
self.assertEqual(len(api_registry_instance._mcp_servers), 5)
|
||||
self.assertEqual(self.mock_session.get.call_count, 2)
|
||||
self.mock_session.get.assert_any_call(
|
||||
f"https://cloudapiregistry.googleapis.com/v1beta/projects/{self.project_id}/locations/{self.location}/mcpServers",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
params={"filter": "enabled=false"},
|
||||
)
|
||||
self.mock_session.get.assert_called_with(
|
||||
f"https://cloudapiregistry.googleapis.com/v1beta/projects/{self.project_id}/locations/{self.location}/mcpServers",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
params={"filter": "enabled=false", "pageToken": "next_page_token"},
|
||||
)
|
||||
|
||||
def test_init_http_error(self):
|
||||
self.mock_session.get.side_effect = requests.exceptions.RequestException(
|
||||
"Connection failed"
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "Error fetching MCP servers"):
|
||||
ApiRegistry(
|
||||
api_registry_project_id=self.project_id, location=self.location
|
||||
)
|
||||
|
||||
def test_init_bad_response(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock(
|
||||
side_effect=requests.exceptions.HTTPError(
|
||||
"Not Found", request=MagicMock(), response=MagicMock()
|
||||
)
|
||||
)
|
||||
self.mock_session.get.return_value = mock_response
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "Error fetching MCP servers"):
|
||||
ApiRegistry(
|
||||
api_registry_project_id=self.project_id, location=self.location
|
||||
)
|
||||
mock_response.raise_for_status.assert_called_once()
|
||||
|
||||
@patch(
|
||||
"google.adk.integrations.api_registry.api_registry.McpToolset",
|
||||
autospec=True,
|
||||
)
|
||||
def test_get_toolset_success(self, MockMcpToolset):
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json = MagicMock(return_value=MOCK_MCP_SERVERS_LIST)
|
||||
self.mock_session.get.return_value = mock_response
|
||||
|
||||
api_registry_instance = ApiRegistry(
|
||||
api_registry_project_id=self.project_id, location=self.location
|
||||
)
|
||||
|
||||
toolset = api_registry_instance.get_toolset("test-mcp-server-1")
|
||||
|
||||
MockMcpToolset.assert_called_once_with(
|
||||
connection_params=StreamableHTTPConnectionParams(
|
||||
url="https://mcp.server1.com",
|
||||
headers={"Authorization": "Bearer mock_token"},
|
||||
),
|
||||
tool_filter=None,
|
||||
tool_name_prefix=None,
|
||||
header_provider=None,
|
||||
)
|
||||
self.assertEqual(toolset, MockMcpToolset.return_value)
|
||||
|
||||
@patch(
|
||||
"google.adk.integrations.api_registry.api_registry.McpToolset",
|
||||
autospec=True,
|
||||
)
|
||||
def test_get_toolset_with_quota_project_id_success(self, MockMcpToolset):
|
||||
self.mock_credentials.quota_project_id = "quota-project"
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = MOCK_MCP_SERVERS_LIST
|
||||
self.mock_session.get.return_value = mock_response
|
||||
|
||||
api_registry_instance = ApiRegistry(
|
||||
api_registry_project_id=self.project_id, location=self.location
|
||||
)
|
||||
|
||||
toolset = api_registry_instance.get_toolset("test-mcp-server-1")
|
||||
|
||||
MockMcpToolset.assert_called_once_with(
|
||||
connection_params=StreamableHTTPConnectionParams(
|
||||
url="https://mcp.server1.com",
|
||||
headers={
|
||||
"Authorization": "Bearer mock_token",
|
||||
"x-goog-user-project": "quota-project",
|
||||
},
|
||||
),
|
||||
tool_filter=None,
|
||||
tool_name_prefix=None,
|
||||
header_provider=None,
|
||||
)
|
||||
self.assertEqual(toolset, MockMcpToolset.return_value)
|
||||
|
||||
@patch(
|
||||
"google.adk.integrations.api_registry.api_registry.McpToolset",
|
||||
autospec=True,
|
||||
)
|
||||
def test_get_toolset_with_filter_and_prefix(self, MockMcpToolset):
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json = MagicMock(return_value=MOCK_MCP_SERVERS_LIST)
|
||||
self.mock_session.get.return_value = mock_response
|
||||
|
||||
api_registry_instance = ApiRegistry(
|
||||
api_registry_project_id=self.project_id, location=self.location
|
||||
)
|
||||
tool_filter = ["tool1"]
|
||||
tool_name_prefix = "prefix_"
|
||||
toolset = api_registry_instance.get_toolset(
|
||||
"test-mcp-server-1",
|
||||
tool_filter=tool_filter,
|
||||
tool_name_prefix=tool_name_prefix,
|
||||
)
|
||||
|
||||
MockMcpToolset.assert_called_once_with(
|
||||
connection_params=StreamableHTTPConnectionParams(
|
||||
url="https://mcp.server1.com",
|
||||
headers={"Authorization": "Bearer mock_token"},
|
||||
),
|
||||
tool_filter=tool_filter,
|
||||
tool_name_prefix=tool_name_prefix,
|
||||
header_provider=None,
|
||||
)
|
||||
self.assertEqual(toolset, MockMcpToolset.return_value)
|
||||
|
||||
def test_get_toolset_url_scheme(self):
|
||||
params = [
|
||||
("test-mcp-server-http", "http://mcp.server_http.com"),
|
||||
("test-mcp-server-https", "https://mcp.server_https.com"),
|
||||
]
|
||||
for mock_server_name, mock_url in params:
|
||||
with self.subTest(server_name=mock_server_name):
|
||||
with (
|
||||
patch.object(
|
||||
api_registry.api_registry, "McpToolset", autospec=True
|
||||
) as MockMcpToolset,
|
||||
):
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = MOCK_MCP_SERVERS_LIST
|
||||
self.mock_session.get.return_value = mock_response
|
||||
|
||||
api_registry_instance = ApiRegistry(
|
||||
api_registry_project_id=self.project_id, location=self.location
|
||||
)
|
||||
|
||||
api_registry_instance.get_toolset(mock_server_name)
|
||||
|
||||
MockMcpToolset.assert_called_once_with(
|
||||
connection_params=StreamableHTTPConnectionParams(
|
||||
url=mock_url,
|
||||
headers={"Authorization": "Bearer mock_token"},
|
||||
),
|
||||
tool_filter=None,
|
||||
tool_name_prefix=None,
|
||||
header_provider=None,
|
||||
)
|
||||
|
||||
def test_get_toolset_server_not_found(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json = MagicMock(return_value=MOCK_MCP_SERVERS_LIST)
|
||||
self.mock_session.get.return_value = mock_response
|
||||
|
||||
api_registry_instance = ApiRegistry(
|
||||
api_registry_project_id=self.project_id, location=self.location
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "not found in API Registry"):
|
||||
api_registry_instance.get_toolset("non-existent-server")
|
||||
|
||||
def test_get_toolset_server_no_url(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json = MagicMock(return_value=MOCK_MCP_SERVERS_LIST)
|
||||
self.mock_session.get.return_value = mock_response
|
||||
|
||||
api_registry_instance = ApiRegistry(
|
||||
api_registry_project_id=self.project_id, location=self.location
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "has no URLs"):
|
||||
api_registry_instance.get_toolset("test-mcp-server-no-url")
|
||||
|
||||
|
||||
class TestApiRegistryMtls(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.project_id = "test-project"
|
||||
self.location = "global"
|
||||
self.mock_credentials = MagicMock()
|
||||
self.mock_credentials.token = "mock_token"
|
||||
self.mock_credentials.refresh = MagicMock()
|
||||
self.mock_credentials.quota_project_id = None
|
||||
mock_auth_patcher = patch(
|
||||
"google.auth.default",
|
||||
return_value=(self.mock_credentials, None),
|
||||
autospec=True,
|
||||
)
|
||||
mock_auth_patcher.start()
|
||||
self.addCleanup(mock_auth_patcher.stop)
|
||||
|
||||
@patch(
|
||||
"google.auth.transport.mtls.has_default_client_cert_source",
|
||||
return_value=True,
|
||||
)
|
||||
@patch("google.auth.transport.mtls.default_client_cert_source")
|
||||
@patch.dict(os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "true"})
|
||||
def test_init_configures_mtls(self, mock_cert_source, _mock_has_cert):
|
||||
mock_cert_source.return_value = lambda: (b"cert", b"key")
|
||||
with (
|
||||
patch(
|
||||
"google.adk.integrations.api_registry.api_registry._mtls_utils.use_client_cert_effective",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"google.auth.transport.requests.AuthorizedSession",
|
||||
autospec=True,
|
||||
) as mock_session_class,
|
||||
):
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.return_value = MOCK_MCP_SERVERS_LIST
|
||||
mock_session = mock_session_class.return_value
|
||||
mock_session.__enter__.return_value = mock_session
|
||||
mock_session.get.return_value = mock_response
|
||||
|
||||
_ = ApiRegistry(
|
||||
api_registry_project_id=self.project_id, location=self.location
|
||||
)
|
||||
|
||||
mock_session.configure_mtls_channel.assert_called_once()
|
||||
args, _ = mock_session.get.call_args
|
||||
self.assertIn("cloudapiregistry.mtls.googleapis.com", args[0])
|
||||
@@ -0,0 +1,306 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import google.adk
|
||||
from google.adk.integrations.bigquery.client import DP_USER_AGENT
|
||||
from google.adk.integrations.bigquery.client import get_bigquery_client
|
||||
from google.adk.integrations.bigquery.client import get_dataplex_catalog_client
|
||||
from google.adk.utils._telemetry_context import _is_visual_builder
|
||||
from google.api_core.gapic_v1 import client_info as gapic_client_info
|
||||
import google.auth
|
||||
from google.auth.exceptions import DefaultCredentialsError
|
||||
from google.cloud import dataplex_v1
|
||||
from google.cloud.bigquery import client as bigquery_client
|
||||
from google.oauth2.credentials import Credentials
|
||||
|
||||
|
||||
def test_bigquery_client_default():
|
||||
"""Test the default BigQuery client properties."""
|
||||
# Trigger the BigQuery client creation
|
||||
client = get_bigquery_client(
|
||||
project="test-gcp-project",
|
||||
credentials=mock.create_autospec(Credentials, instance=True),
|
||||
)
|
||||
|
||||
# Verify that the client has the desired project set
|
||||
assert client.project == "test-gcp-project"
|
||||
assert client.location is None
|
||||
|
||||
|
||||
def test_bigquery_client_project_set_explicit():
|
||||
"""Test BigQuery client creation does not invoke default auth."""
|
||||
# Let's simulate that no environment variables are set, so that any project
|
||||
# set in there does not interfere with this test
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
with mock.patch.object(
|
||||
google.auth, "default", autospec=True
|
||||
) as mock_default_auth:
|
||||
# Simulate exception from default auth
|
||||
mock_default_auth.side_effect = DefaultCredentialsError(
|
||||
"Your default credentials were not found"
|
||||
)
|
||||
|
||||
# Trigger the BigQuery client creation
|
||||
client = get_bigquery_client(
|
||||
project="test-gcp-project",
|
||||
credentials=mock.create_autospec(Credentials, instance=True),
|
||||
)
|
||||
|
||||
# If we are here that already means client creation did not call default
|
||||
# auth (otherwise we would have run into DefaultCredentialsError set
|
||||
# above). For the sake of explicitness, trivially assert that the default
|
||||
# auth was not called, and yet the project was set correctly
|
||||
mock_default_auth.assert_not_called()
|
||||
assert client.project == "test-gcp-project"
|
||||
|
||||
|
||||
def test_bigquery_client_project_set_with_default_auth():
|
||||
"""Test BigQuery client creation invokes default auth to set the project."""
|
||||
# Let's simulate that no environment variables are set, so that any project
|
||||
# set in there does not interfere with this test
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
with mock.patch.object(
|
||||
google.auth, "default", autospec=True
|
||||
) as mock_default_auth:
|
||||
# Simulate credentials
|
||||
mock_creds = mock.create_autospec(Credentials, instance=True)
|
||||
|
||||
# Simulate output of the default auth
|
||||
mock_default_auth.return_value = (mock_creds, "test-gcp-project")
|
||||
|
||||
# Trigger the BigQuery client creation
|
||||
client = get_bigquery_client(
|
||||
project=None,
|
||||
credentials=mock_creds,
|
||||
)
|
||||
|
||||
# Verify that default auth was called once to set the client project
|
||||
mock_default_auth.assert_called_once()
|
||||
assert client.project == "test-gcp-project"
|
||||
|
||||
|
||||
def test_bigquery_client_project_set_with_env():
|
||||
"""Test BigQuery client creation sets the project from environment variable."""
|
||||
# Let's simulate the project set in environment variables
|
||||
with mock.patch.dict(
|
||||
os.environ, {"GOOGLE_CLOUD_PROJECT": "test-gcp-project"}, clear=True
|
||||
):
|
||||
with mock.patch.object(
|
||||
google.auth, "default", autospec=True
|
||||
) as mock_default_auth:
|
||||
# Simulate exception from default auth
|
||||
mock_default_auth.side_effect = DefaultCredentialsError(
|
||||
"Your default credentials were not found"
|
||||
)
|
||||
|
||||
# Trigger the BigQuery client creation
|
||||
client = get_bigquery_client(
|
||||
project=None,
|
||||
credentials=mock.create_autospec(Credentials, instance=True),
|
||||
)
|
||||
|
||||
# If we are here that already means client creation did not call default
|
||||
# auth (otherwise we would have run into DefaultCredentialsError set
|
||||
# above). For the sake of explicitness, trivially assert that the default
|
||||
# auth was not called, and yet the project was set correctly
|
||||
mock_default_auth.assert_not_called()
|
||||
assert client.project == "test-gcp-project"
|
||||
|
||||
|
||||
def test_bigquery_client_user_agent_default():
|
||||
"""Test BigQuery client default user agent."""
|
||||
with mock.patch.object(
|
||||
bigquery_client, "Connection", autospec=True
|
||||
) as mock_connection:
|
||||
# Trigger the BigQuery client creation
|
||||
get_bigquery_client(
|
||||
project="test-gcp-project",
|
||||
credentials=mock.create_autospec(Credentials, instance=True),
|
||||
)
|
||||
|
||||
# Verify that the tracking user agent was set
|
||||
client_info_arg = mock_connection.call_args[1].get("client_info")
|
||||
assert client_info_arg is not None
|
||||
expected_user_agents = {
|
||||
"adk-bigquery-tool",
|
||||
f"google-adk/{google.adk.__version__}",
|
||||
}
|
||||
actual_user_agents = set(client_info_arg.user_agent.split())
|
||||
assert expected_user_agents.issubset(actual_user_agents)
|
||||
|
||||
|
||||
def test_bigquery_client_user_agent_custom():
|
||||
"""Test BigQuery client custom user agent."""
|
||||
with mock.patch.object(
|
||||
bigquery_client, "Connection", autospec=True
|
||||
) as mock_connection:
|
||||
# Trigger the BigQuery client creation
|
||||
get_bigquery_client(
|
||||
project="test-gcp-project",
|
||||
credentials=mock.create_autospec(Credentials, instance=True),
|
||||
user_agent="custom_user_agent",
|
||||
)
|
||||
|
||||
# Verify that the tracking user agent was set
|
||||
client_info_arg = mock_connection.call_args[1].get("client_info")
|
||||
assert client_info_arg is not None
|
||||
expected_user_agents = {
|
||||
"adk-bigquery-tool",
|
||||
f"google-adk/{google.adk.__version__}",
|
||||
"custom_user_agent",
|
||||
}
|
||||
actual_user_agents = set(client_info_arg.user_agent.split())
|
||||
assert expected_user_agents.issubset(actual_user_agents)
|
||||
|
||||
|
||||
def test_bigquery_client_user_agent_custom_list():
|
||||
"""Test BigQuery client custom user agent."""
|
||||
with mock.patch.object(
|
||||
bigquery_client, "Connection", autospec=True
|
||||
) as mock_connection:
|
||||
# Trigger the BigQuery client creation
|
||||
get_bigquery_client(
|
||||
project="test-gcp-project",
|
||||
credentials=mock.create_autospec(Credentials, instance=True),
|
||||
user_agent=["custom_user_agent1", "custom_user_agent2"],
|
||||
)
|
||||
|
||||
# Verify that the tracking user agents were set
|
||||
client_info_arg = mock_connection.call_args[1].get("client_info")
|
||||
assert client_info_arg is not None
|
||||
expected_user_agents = {
|
||||
"adk-bigquery-tool",
|
||||
f"google-adk/{google.adk.__version__}",
|
||||
"custom_user_agent1",
|
||||
"custom_user_agent2",
|
||||
}
|
||||
actual_user_agents = set(client_info_arg.user_agent.split())
|
||||
assert expected_user_agents.issubset(actual_user_agents)
|
||||
|
||||
|
||||
def test_bigquery_client_user_agent_visual_builder():
|
||||
"""Test BigQuery client user agent when visual builder flag is set."""
|
||||
token = _is_visual_builder.set(True)
|
||||
try:
|
||||
with mock.patch.object(
|
||||
bigquery_client, "Connection", autospec=True
|
||||
) as mock_connection:
|
||||
# Trigger the BigQuery client creation
|
||||
get_bigquery_client(
|
||||
project="test-gcp-project",
|
||||
credentials=mock.create_autospec(Credentials, instance=True),
|
||||
)
|
||||
|
||||
# Verify that the tracking user agent was set
|
||||
client_info_arg = mock_connection.call_args[1].get("client_info")
|
||||
assert client_info_arg is not None
|
||||
expected_user_agents = {
|
||||
"adk-bigquery-tool",
|
||||
f"google-adk/{google.adk.__version__}",
|
||||
"google-adk-visual-builder",
|
||||
}
|
||||
actual_user_agents = set(client_info_arg.user_agent.split())
|
||||
assert expected_user_agents.issubset(actual_user_agents)
|
||||
finally:
|
||||
_is_visual_builder.reset(token)
|
||||
|
||||
|
||||
def test_bigquery_client_location_custom():
|
||||
"""Test BigQuery client custom location."""
|
||||
# Trigger the BigQuery client creation
|
||||
client = get_bigquery_client(
|
||||
project="test-gcp-project",
|
||||
credentials=mock.create_autospec(Credentials, instance=True),
|
||||
location="us-central1",
|
||||
)
|
||||
|
||||
# Verify that the client has the desired project set
|
||||
assert client.project == "test-gcp-project"
|
||||
assert client.location == "us-central1"
|
||||
|
||||
|
||||
# Tests for Dataplex Catalog Client
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Mock the CatalogServiceClient class directly
|
||||
@mock.patch.object(dataplex_v1, "CatalogServiceClient", autospec=True)
|
||||
def test_dataplex_client_default(mock_catalog_service_client):
|
||||
"""Test get_dataplex_catalog_client with default user agent."""
|
||||
mock_creds = mock.create_autospec(Credentials, instance=True)
|
||||
|
||||
client = get_dataplex_catalog_client(credentials=mock_creds)
|
||||
|
||||
mock_catalog_service_client.assert_called_once()
|
||||
_, kwargs = mock_catalog_service_client.call_args
|
||||
|
||||
assert kwargs["credentials"] == mock_creds
|
||||
client_info = kwargs["client_info"]
|
||||
assert isinstance(client_info, gapic_client_info.ClientInfo)
|
||||
assert client_info.user_agent == DP_USER_AGENT
|
||||
|
||||
# Ensure the function returns the mock instance
|
||||
assert client == mock_catalog_service_client.return_value
|
||||
|
||||
|
||||
@mock.patch.object(dataplex_v1, "CatalogServiceClient", autospec=True)
|
||||
def test_dataplex_client_custom_user_agent_str(mock_catalog_service_client):
|
||||
"""Test get_dataplex_catalog_client with a custom user agent string."""
|
||||
mock_creds = mock.create_autospec(Credentials, instance=True)
|
||||
custom_ua = "catalog_ua/1.0"
|
||||
expected_ua = f"{DP_USER_AGENT} {custom_ua}"
|
||||
|
||||
get_dataplex_catalog_client(credentials=mock_creds, user_agent=custom_ua)
|
||||
|
||||
mock_catalog_service_client.assert_called_once()
|
||||
_, kwargs = mock_catalog_service_client.call_args
|
||||
client_info = kwargs["client_info"]
|
||||
assert client_info.user_agent == expected_ua
|
||||
|
||||
|
||||
@mock.patch.object(dataplex_v1, "CatalogServiceClient", autospec=True)
|
||||
def test_dataplex_client_custom_user_agent_list(mock_catalog_service_client):
|
||||
"""Test get_dataplex_catalog_client with a custom user agent list."""
|
||||
mock_creds = mock.create_autospec(Credentials, instance=True)
|
||||
custom_ua_list = ["catalog_ua", "catalog_ua_2.0"]
|
||||
expected_ua = f"{DP_USER_AGENT} {' '.join(custom_ua_list)}"
|
||||
|
||||
get_dataplex_catalog_client(credentials=mock_creds, user_agent=custom_ua_list)
|
||||
|
||||
mock_catalog_service_client.assert_called_once()
|
||||
_, kwargs = mock_catalog_service_client.call_args
|
||||
client_info = kwargs["client_info"]
|
||||
assert client_info.user_agent == expected_ua
|
||||
|
||||
|
||||
@mock.patch.object(dataplex_v1, "CatalogServiceClient", autospec=True)
|
||||
def test_dataplex_client_custom_user_agent_list_with_none(
|
||||
mock_catalog_service_client,
|
||||
):
|
||||
"""Test get_dataplex_catalog_client with a list containing None."""
|
||||
mock_creds = mock.create_autospec(Credentials, instance=True)
|
||||
custom_ua_list = ["catalog_ua", None, "catalog_ua_2.0"]
|
||||
expected_ua = f"{DP_USER_AGENT} catalog_ua catalog_ua_2.0"
|
||||
|
||||
get_dataplex_catalog_client(credentials=mock_creds, user_agent=custom_ua_list)
|
||||
|
||||
mock_catalog_service_client.assert_called_once()
|
||||
_, kwargs = mock_catalog_service_client.call_args
|
||||
client_info = kwargs["client_info"]
|
||||
assert client_info.user_agent == expected_ua
|
||||
@@ -0,0 +1,189 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.integrations.bigquery import BigQueryCredentialsConfig
|
||||
# Mock the Google OAuth and API dependencies
|
||||
import google.auth.credentials
|
||||
import google.oauth2.credentials
|
||||
import pytest
|
||||
|
||||
|
||||
class TestBigQueryCredentials:
|
||||
"""Test suite for BigQueryCredentials configuration validation.
|
||||
|
||||
This class tests the credential configuration logic that ensures
|
||||
either existing credentials or client ID/secret pairs are provided.
|
||||
"""
|
||||
|
||||
def test_valid_credentials_object_auth_credentials(self):
|
||||
"""Test that providing valid Credentials object works correctly with
|
||||
google.auth.credentials.Credentials.
|
||||
|
||||
When a user already has valid OAuth credentials, they should be able
|
||||
to pass them directly without needing to provide client ID/secret.
|
||||
"""
|
||||
# Create a mock auth credentials object
|
||||
auth_creds = mock.create_autospec(
|
||||
google.auth.credentials.Credentials, instance=True
|
||||
)
|
||||
|
||||
config = BigQueryCredentialsConfig(credentials=auth_creds)
|
||||
|
||||
# Verify that the credentials are properly stored and attributes are extracted
|
||||
assert config.credentials == auth_creds
|
||||
assert config.client_secret is None
|
||||
assert config.scopes == [
|
||||
"https://www.googleapis.com/auth/bigquery",
|
||||
"https://www.googleapis.com/auth/dataplex.read-write",
|
||||
]
|
||||
|
||||
def test_valid_credentials_object_oauth2_credentials(self):
|
||||
"""Test that providing valid Credentials object works correctly with
|
||||
google.oauth2.credentials.Credentials.
|
||||
|
||||
When a user already has valid OAuth credentials, they should be able
|
||||
to pass them directly without needing to provide client ID/secret.
|
||||
"""
|
||||
# Create a mock oauth2 credentials object
|
||||
oauth2_creds = google.oauth2.credentials.Credentials(
|
||||
"test_token",
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
scopes=["https://www.googleapis.com/auth/calendar"],
|
||||
)
|
||||
|
||||
config = BigQueryCredentialsConfig(credentials=oauth2_creds)
|
||||
|
||||
# Verify that the credentials are properly stored and attributes are extracted
|
||||
assert config.credentials == oauth2_creds
|
||||
assert config.client_id == "test_client_id"
|
||||
assert config.client_secret == "test_client_secret"
|
||||
assert config.scopes == ["https://www.googleapis.com/auth/calendar"]
|
||||
|
||||
def test_valid_client_id_secret_pair_default_scope(self):
|
||||
"""Test that providing client ID and secret with default scope works.
|
||||
|
||||
This tests the scenario where users want to create new OAuth credentials
|
||||
from scratch using their application's client ID and secret and does not
|
||||
specify the scopes explicitly.
|
||||
"""
|
||||
config = BigQueryCredentialsConfig(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
)
|
||||
|
||||
assert config.credentials is None
|
||||
assert config.client_id == "test_client_id"
|
||||
assert config.client_secret == "test_client_secret"
|
||||
assert config.scopes == [
|
||||
"https://www.googleapis.com/auth/bigquery",
|
||||
"https://www.googleapis.com/auth/dataplex.read-write",
|
||||
]
|
||||
|
||||
def test_valid_client_id_secret_pair_w_scope(self):
|
||||
"""Test that providing client ID and secret with explicit scopes works.
|
||||
|
||||
This tests the scenario where users want to create new OAuth credentials
|
||||
from scratch using their application's client ID and secret and does specify
|
||||
the scopes explicitly.
|
||||
"""
|
||||
config = BigQueryCredentialsConfig(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
scopes=[
|
||||
"https://www.googleapis.com/auth/bigquery",
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
],
|
||||
)
|
||||
|
||||
assert config.credentials is None
|
||||
assert config.client_id == "test_client_id"
|
||||
assert config.client_secret == "test_client_secret"
|
||||
assert config.scopes == [
|
||||
"https://www.googleapis.com/auth/bigquery",
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
]
|
||||
|
||||
def test_valid_client_id_secret_pair_w_empty_scope(self):
|
||||
"""Test that providing client ID and secret with empty scope works.
|
||||
|
||||
This tests the corner case scenario where users want to create new OAuth
|
||||
credentials from scratch using their application's client ID and secret but
|
||||
specifies empty scope, in which case the default BQ scope is used.
|
||||
"""
|
||||
config = BigQueryCredentialsConfig(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
scopes=[],
|
||||
)
|
||||
|
||||
assert config.credentials is None
|
||||
assert config.client_id == "test_client_id"
|
||||
assert config.client_secret == "test_client_secret"
|
||||
assert config.scopes == [
|
||||
"https://www.googleapis.com/auth/bigquery",
|
||||
"https://www.googleapis.com/auth/dataplex.read-write",
|
||||
]
|
||||
|
||||
def test_missing_client_secret_raises_error(self):
|
||||
"""Test that missing client secret raises appropriate validation error.
|
||||
|
||||
This ensures that incomplete OAuth configuration is caught early
|
||||
rather than failing during runtime.
|
||||
"""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Must provide one of credentials, external_access_token_key, or"
|
||||
" client_id and client_secret pair"
|
||||
),
|
||||
):
|
||||
BigQueryCredentialsConfig(client_id="test_client_id")
|
||||
|
||||
def test_missing_client_id_raises_error(self):
|
||||
"""Test that missing client ID raises appropriate validation error."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Must provide one of credentials, external_access_token_key, or"
|
||||
" client_id and client_secret pair"
|
||||
),
|
||||
):
|
||||
BigQueryCredentialsConfig(client_secret="test_client_secret")
|
||||
|
||||
def test_empty_configuration_raises_error(self):
|
||||
"""Test that completely empty configuration is rejected.
|
||||
|
||||
Users must provide either existing credentials or the components
|
||||
needed to create new ones.
|
||||
"""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Must provide one of credentials, external_access_token_key, or"
|
||||
" client_id and client_secret pair"
|
||||
),
|
||||
):
|
||||
BigQueryCredentialsConfig()
|
||||
|
||||
def test_invalid_property_raises_error(self):
|
||||
"""Test BigQueryCredentialsConfig raises exception when setting invalid property."""
|
||||
with pytest.raises(ValueError):
|
||||
BigQueryCredentialsConfig(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
non_existent_field="some value",
|
||||
)
|
||||
@@ -0,0 +1,185 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import pathlib
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.integrations.bigquery import data_insights_tool
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case_file_path",
|
||||
[
|
||||
pytest.param("test_data/ask_data_insights_penguins_highest_mass.yaml"),
|
||||
],
|
||||
)
|
||||
@mock.patch.object(
|
||||
data_insights_tool._gda_stream_util, "get_gda_session", autospec=True
|
||||
)
|
||||
def test_ask_data_insights_pipeline_from_file(mock_get_session, case_file_path):
|
||||
"""Runs a full integration test for the ask_data_insights pipeline using data from a specific file."""
|
||||
# 1. Construct the full, absolute path to the data file
|
||||
full_path = pathlib.Path(__file__).parent / case_file_path
|
||||
|
||||
# 2. Load the test case data from the specified YAML file
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
case_data = yaml.safe_load(f)
|
||||
|
||||
# 3. Prepare the mock stream and expected output from the loaded data
|
||||
mock_stream_str = case_data["mock_api_stream"]
|
||||
fake_stream_lines = [
|
||||
line.encode("utf-8") for line in mock_stream_str.splitlines()
|
||||
]
|
||||
# Load the expected output as a list of dictionaries, not a single string
|
||||
expected_final_list = case_data["expected_output"]
|
||||
|
||||
# 4. Configure the mock for requests.post
|
||||
mock_session = mock.MagicMock()
|
||||
mock_response = mock.Mock()
|
||||
mock_response.iter_lines.return_value = fake_stream_lines
|
||||
# Add raise_for_status mock which is called in the updated code
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_session.post.return_value.__enter__.return_value = mock_response
|
||||
mock_get_session.return_value = (
|
||||
mock_session,
|
||||
"https://geminidataanalytics.mtls.googleapis.com",
|
||||
)
|
||||
|
||||
# 5. Call the function under test
|
||||
mock_creds = mock.Mock()
|
||||
mock_settings = mock.Mock()
|
||||
mock_settings.max_query_result_rows = 50
|
||||
result = data_insights_tool.ask_data_insights(
|
||||
project_id="test-project",
|
||||
user_query_with_context=case_data["user_question"],
|
||||
table_references=[],
|
||||
credentials=mock_creds,
|
||||
settings=mock_settings,
|
||||
)
|
||||
|
||||
# 6. Assert that the final list of dicts matches the expected output
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert result["response"] == expected_final_list
|
||||
mock_get_session.assert_called_once_with(mock_creds)
|
||||
mock_session.post.assert_called_once_with(
|
||||
"https://geminidataanalytics.mtls.googleapis.com/v1beta/projects/test-project/locations/global:chat",
|
||||
json={
|
||||
"project": "projects/test-project",
|
||||
"messages": [{"userMessage": {"text": case_data["user_question"]}}],
|
||||
"inlineContext": {
|
||||
"datasourceReferences": {"bq": {"tableReferences": []}},
|
||||
"systemInstruction": mock.ANY,
|
||||
"options": {"chart": {"image": {"noImage": {}}}},
|
||||
},
|
||||
"clientIdEnum": "GOOGLE_ADK",
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-API-Client": "GOOGLE_ADK",
|
||||
},
|
||||
stream=True,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(data_insights_tool._gda_stream_util, "get_stream")
|
||||
@mock.patch.object(
|
||||
data_insights_tool._gda_stream_util, "get_gda_session", autospec=True
|
||||
)
|
||||
def test_ask_data_insights_success(mock_get_session, mock_get_stream):
|
||||
"""Tests the success path of ask_data_insights using decorators."""
|
||||
# 1. Configure the behavior of the mocked functions
|
||||
mock_stream = [
|
||||
{"text": {"parts": ["response1"], "textType": "THOUGHT"}},
|
||||
{"text": {"parts": ["response2"], "textType": "FINAL_RESPONSE"}},
|
||||
]
|
||||
mock_get_stream.return_value = mock_stream
|
||||
mock_session = mock.MagicMock()
|
||||
mock_get_session.return_value = (
|
||||
mock_session,
|
||||
"https://geminidataanalytics.mtls.googleapis.com",
|
||||
)
|
||||
|
||||
# 2. Create mock inputs for the function call
|
||||
mock_creds = mock.Mock()
|
||||
mock_settings = mock.Mock()
|
||||
mock_settings.max_query_result_rows = 100
|
||||
|
||||
# 3. Call the function under test
|
||||
result = data_insights_tool.ask_data_insights(
|
||||
project_id="test-project",
|
||||
user_query_with_context="test query",
|
||||
table_references=[],
|
||||
credentials=mock_creds,
|
||||
settings=mock_settings,
|
||||
)
|
||||
|
||||
# 4. Assert the results are as expected
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert result["response"] == mock_stream
|
||||
mock_get_session.assert_called_once_with(mock_creds)
|
||||
mock_get_stream.assert_called_once_with(
|
||||
mock_session,
|
||||
"https://geminidataanalytics.mtls.googleapis.com/v1beta/projects/test-project/locations/global:chat",
|
||||
{
|
||||
"project": "projects/test-project",
|
||||
"messages": [{"userMessage": {"text": "test query"}}],
|
||||
"inlineContext": {
|
||||
"datasourceReferences": {"bq": {"tableReferences": []}},
|
||||
"systemInstruction": mock.ANY,
|
||||
"options": {"chart": {"image": {"noImage": {}}}},
|
||||
},
|
||||
"clientIdEnum": "GOOGLE_ADK",
|
||||
},
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-API-Client": "GOOGLE_ADK",
|
||||
},
|
||||
100,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(data_insights_tool._gda_stream_util, "get_stream")
|
||||
@mock.patch.object(
|
||||
data_insights_tool._gda_stream_util, "get_gda_session", autospec=True
|
||||
)
|
||||
def test_ask_data_insights_handles_exception(mock_get_session, mock_get_stream):
|
||||
"""Tests the exception path of ask_data_insights using decorators."""
|
||||
# 1. Configure one of the mocks to raise an error
|
||||
mock_get_stream.side_effect = Exception("API call failed!")
|
||||
mock_session = mock.MagicMock()
|
||||
mock_get_session.return_value = (
|
||||
mock_session,
|
||||
"https://geminidataanalytics.mtls.googleapis.com",
|
||||
)
|
||||
|
||||
# 2. Create mock inputs
|
||||
mock_creds = mock.Mock()
|
||||
mock_settings = mock.Mock()
|
||||
|
||||
# 3. Call the function
|
||||
result = data_insights_tool.ask_data_insights(
|
||||
project_id="test-project",
|
||||
user_query_with_context="test query",
|
||||
table_references=[],
|
||||
credentials=mock_creds,
|
||||
settings=mock_settings,
|
||||
)
|
||||
|
||||
# 4. Assert that the error was caught and formatted correctly
|
||||
assert result["status"] == "ERROR"
|
||||
assert "API call failed!" in result["error_details"]
|
||||
mock_get_session.assert_called_once_with(mock_creds)
|
||||
mock_get_stream.assert_called_once()
|
||||
@@ -0,0 +1,286 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.integrations.bigquery import client as bq_client_lib
|
||||
from google.adk.integrations.bigquery import metadata_tool
|
||||
from google.adk.integrations.bigquery.config import BigQueryToolConfig
|
||||
import google.auth
|
||||
from google.auth.exceptions import DefaultCredentialsError
|
||||
from google.cloud import bigquery
|
||||
from google.oauth2.credentials import Credentials
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
@mock.patch.object(bigquery.Client, "list_datasets", autospec=True)
|
||||
@mock.patch.object(google.auth, "default", autospec=True)
|
||||
def test_list_dataset_ids_no_default_auth(
|
||||
mock_default_auth, mock_list_datasets
|
||||
):
|
||||
"""Test list_dataset_ids tool invocation involves no default auth."""
|
||||
project = "my_project_id"
|
||||
mock_credentials = mock.create_autospec(Credentials, instance=True)
|
||||
tool_settings = BigQueryToolConfig()
|
||||
|
||||
# Simulate the behavior of default auth - on purpose throw exception when
|
||||
# the default auth is called
|
||||
mock_default_auth.side_effect = DefaultCredentialsError(
|
||||
"Your default credentials were not found"
|
||||
)
|
||||
|
||||
mock_list_datasets.return_value = [
|
||||
bigquery.DatasetReference(project, "dataset1"),
|
||||
bigquery.DatasetReference(project, "dataset2"),
|
||||
]
|
||||
result = metadata_tool.list_dataset_ids(
|
||||
project, mock_credentials, tool_settings
|
||||
)
|
||||
assert result == ["dataset1", "dataset2"]
|
||||
mock_default_auth.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
@mock.patch.object(bigquery.Client, "get_dataset", autospec=True)
|
||||
@mock.patch.object(google.auth, "default", autospec=True)
|
||||
def test_get_dataset_info_no_default_auth(mock_default_auth, mock_get_dataset):
|
||||
"""Test get_dataset_info tool invocation involves no default auth."""
|
||||
mock_credentials = mock.create_autospec(Credentials, instance=True)
|
||||
tool_settings = BigQueryToolConfig()
|
||||
|
||||
# Simulate the behavior of default auth - on purpose throw exception when
|
||||
# the default auth is called
|
||||
mock_default_auth.side_effect = DefaultCredentialsError(
|
||||
"Your default credentials were not found"
|
||||
)
|
||||
|
||||
mock_get_dataset.return_value = mock.create_autospec(
|
||||
Credentials, instance=True
|
||||
)
|
||||
result = metadata_tool.get_dataset_info(
|
||||
"my_project_id", "my_dataset_id", mock_credentials, tool_settings
|
||||
)
|
||||
assert result != {
|
||||
"status": "ERROR",
|
||||
"error_details": "Your default credentials were not found",
|
||||
}
|
||||
mock_default_auth.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
@mock.patch.object(bigquery.Client, "list_tables", autospec=True)
|
||||
@mock.patch.object(google.auth, "default", autospec=True)
|
||||
def test_list_table_ids_no_default_auth(mock_default_auth, mock_list_tables):
|
||||
"""Test list_table_ids tool invocation involves no default auth."""
|
||||
project = "my_project_id"
|
||||
dataset = "my_dataset_id"
|
||||
dataset_ref = bigquery.DatasetReference(project, dataset)
|
||||
mock_credentials = mock.create_autospec(Credentials, instance=True)
|
||||
tool_settings = BigQueryToolConfig()
|
||||
|
||||
# Simulate the behavior of default auth - on purpose throw exception when
|
||||
# the default auth is called
|
||||
mock_default_auth.side_effect = DefaultCredentialsError(
|
||||
"Your default credentials were not found"
|
||||
)
|
||||
|
||||
mock_list_tables.return_value = [
|
||||
bigquery.TableReference(dataset_ref, "table1"),
|
||||
bigquery.TableReference(dataset_ref, "table2"),
|
||||
]
|
||||
result = metadata_tool.list_table_ids(
|
||||
project, dataset, mock_credentials, tool_settings
|
||||
)
|
||||
assert result == ["table1", "table2"]
|
||||
mock_default_auth.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
@mock.patch.object(bigquery.Client, "get_table", autospec=True)
|
||||
@mock.patch.object(google.auth, "default", autospec=True)
|
||||
def test_get_table_info_no_default_auth(mock_default_auth, mock_get_table):
|
||||
"""Test get_table_info tool invocation involves no default auth."""
|
||||
mock_credentials = mock.create_autospec(Credentials, instance=True)
|
||||
tool_settings = BigQueryToolConfig()
|
||||
|
||||
# Simulate the behavior of default auth - on purpose throw exception when
|
||||
# the default auth is called
|
||||
mock_default_auth.side_effect = DefaultCredentialsError(
|
||||
"Your default credentials were not found"
|
||||
)
|
||||
|
||||
mock_get_table.return_value = mock.create_autospec(Credentials, instance=True)
|
||||
result = metadata_tool.get_table_info(
|
||||
"my_project_id",
|
||||
"my_dataset_id",
|
||||
"my_table_id",
|
||||
mock_credentials,
|
||||
tool_settings,
|
||||
)
|
||||
assert result != {
|
||||
"status": "ERROR",
|
||||
"error_details": "Your default credentials were not found",
|
||||
}
|
||||
mock_default_auth.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
@mock.patch.object(bigquery.Client, "get_job", autospec=True)
|
||||
@mock.patch.object(google.auth, "default", autospec=True)
|
||||
def test_get_job_info_no_default_auth(mock_default_auth, mock_get_job):
|
||||
"""Test get_job_info tool invocation involves no default auth."""
|
||||
mock_credentials = mock.create_autospec(Credentials, instance=True)
|
||||
tool_settings = BigQueryToolConfig()
|
||||
|
||||
# Simulate the behavior of default auth - on purpose throw exception when
|
||||
# the default auth is called
|
||||
mock_default_auth.side_effect = DefaultCredentialsError(
|
||||
"Your default credentials were not found"
|
||||
)
|
||||
|
||||
mock_get_job.return_value = mock.create_autospec(
|
||||
bigquery.QueryJob, instance=True
|
||||
)
|
||||
result = metadata_tool.get_job_info(
|
||||
"my_project_id",
|
||||
"my_job_id",
|
||||
mock_credentials,
|
||||
tool_settings,
|
||||
)
|
||||
assert result != {
|
||||
"status": "ERROR",
|
||||
"error_details": "Your default credentials were not found",
|
||||
}
|
||||
mock_default_auth.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(bq_client_lib, "get_bigquery_client", autospec=True)
|
||||
def test_list_dataset_ids_bq_client_creation(mock_get_bigquery_client):
|
||||
"""Test BigQuery client creation params during list_dataset_ids tool invocation."""
|
||||
bq_project = "my_project_id"
|
||||
bq_credentials = mock.create_autospec(Credentials, instance=True)
|
||||
application_name = "my-agent"
|
||||
tool_settings = BigQueryToolConfig(application_name=application_name)
|
||||
|
||||
metadata_tool.list_dataset_ids(bq_project, bq_credentials, tool_settings)
|
||||
mock_get_bigquery_client.assert_called_once()
|
||||
assert len(mock_get_bigquery_client.call_args.kwargs) == 4
|
||||
assert mock_get_bigquery_client.call_args.kwargs["project"] == bq_project
|
||||
assert (
|
||||
mock_get_bigquery_client.call_args.kwargs["credentials"] == bq_credentials
|
||||
)
|
||||
assert mock_get_bigquery_client.call_args.kwargs["user_agent"] == [
|
||||
application_name,
|
||||
"list_dataset_ids",
|
||||
]
|
||||
|
||||
|
||||
@mock.patch.object(bq_client_lib, "get_bigquery_client", autospec=True)
|
||||
def test_get_dataset_info_bq_client_creation(mock_get_bigquery_client):
|
||||
"""Test BigQuery client creation params during get_dataset_info tool invocation."""
|
||||
bq_project = "my_project_id"
|
||||
bq_dataset = "my_dataset_id"
|
||||
bq_credentials = mock.create_autospec(Credentials, instance=True)
|
||||
application_name = "my-agent"
|
||||
tool_settings = BigQueryToolConfig(application_name=application_name)
|
||||
|
||||
metadata_tool.get_dataset_info(
|
||||
bq_project, bq_dataset, bq_credentials, tool_settings
|
||||
)
|
||||
mock_get_bigquery_client.assert_called_once()
|
||||
assert len(mock_get_bigquery_client.call_args.kwargs) == 4
|
||||
assert mock_get_bigquery_client.call_args.kwargs["project"] == bq_project
|
||||
assert (
|
||||
mock_get_bigquery_client.call_args.kwargs["credentials"] == bq_credentials
|
||||
)
|
||||
assert mock_get_bigquery_client.call_args.kwargs["user_agent"] == [
|
||||
application_name,
|
||||
"get_dataset_info",
|
||||
]
|
||||
|
||||
|
||||
@mock.patch.object(bq_client_lib, "get_bigquery_client", autospec=True)
|
||||
def test_list_table_ids_bq_client_creation(mock_get_bigquery_client):
|
||||
"""Test BigQuery client creation params during list_table_ids tool invocation."""
|
||||
bq_project = "my_project_id"
|
||||
bq_dataset = "my_dataset_id"
|
||||
bq_credentials = mock.create_autospec(Credentials, instance=True)
|
||||
application_name = "my-agent"
|
||||
tool_settings = BigQueryToolConfig(application_name=application_name)
|
||||
|
||||
metadata_tool.list_table_ids(
|
||||
bq_project, bq_dataset, bq_credentials, tool_settings
|
||||
)
|
||||
mock_get_bigquery_client.assert_called_once()
|
||||
assert len(mock_get_bigquery_client.call_args.kwargs) == 4
|
||||
assert mock_get_bigquery_client.call_args.kwargs["project"] == bq_project
|
||||
assert (
|
||||
mock_get_bigquery_client.call_args.kwargs["credentials"] == bq_credentials
|
||||
)
|
||||
assert mock_get_bigquery_client.call_args.kwargs["user_agent"] == [
|
||||
application_name,
|
||||
"list_table_ids",
|
||||
]
|
||||
|
||||
|
||||
@mock.patch.object(bq_client_lib, "get_bigquery_client", autospec=True)
|
||||
def test_get_table_info_bq_client_creation(mock_get_bigquery_client):
|
||||
"""Test BigQuery client creation params during get_table_info tool invocation."""
|
||||
bq_project = "my_project_id"
|
||||
bq_dataset = "my_dataset_id"
|
||||
bq_table = "my_table_id"
|
||||
bq_credentials = mock.create_autospec(Credentials, instance=True)
|
||||
application_name = "my-agent"
|
||||
tool_settings = BigQueryToolConfig(application_name=application_name)
|
||||
|
||||
metadata_tool.get_table_info(
|
||||
bq_project, bq_dataset, bq_table, bq_credentials, tool_settings
|
||||
)
|
||||
mock_get_bigquery_client.assert_called_once()
|
||||
assert len(mock_get_bigquery_client.call_args.kwargs) == 4
|
||||
assert mock_get_bigquery_client.call_args.kwargs["project"] == bq_project
|
||||
assert (
|
||||
mock_get_bigquery_client.call_args.kwargs["credentials"] == bq_credentials
|
||||
)
|
||||
assert mock_get_bigquery_client.call_args.kwargs["user_agent"] == [
|
||||
application_name,
|
||||
"get_table_info",
|
||||
]
|
||||
|
||||
|
||||
@mock.patch.object(bq_client_lib, "get_bigquery_client", autospec=True)
|
||||
def test_get_job_info_bq_client_creation(mock_get_bigquery_client):
|
||||
"""Test BigQuery client creation params during get_table_info tool invocation."""
|
||||
bq_project = "my_project_id"
|
||||
bq_job_id = "my_job_id"
|
||||
bq_credentials = mock.create_autospec(Credentials, instance=True)
|
||||
application_name = "my-agent"
|
||||
tool_settings = BigQueryToolConfig(application_name=application_name)
|
||||
|
||||
metadata_tool.get_job_info(
|
||||
bq_project, bq_job_id, bq_credentials, tool_settings
|
||||
)
|
||||
mock_get_bigquery_client.assert_called_once()
|
||||
assert len(mock_get_bigquery_client.call_args.kwargs) == 4
|
||||
assert mock_get_bigquery_client.call_args.kwargs["project"] == bq_project
|
||||
assert (
|
||||
mock_get_bigquery_client.call_args.kwargs["credentials"] == bq_credentials
|
||||
)
|
||||
assert mock_get_bigquery_client.call_args.kwargs["user_agent"] == [
|
||||
application_name,
|
||||
"get_job_info",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,448 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from absl.testing import parameterized
|
||||
|
||||
# Mock google.genai and pydantic if not available, before importing google.adk modules
|
||||
try:
|
||||
import google.genai
|
||||
except ImportError:
|
||||
m = mock.MagicMock()
|
||||
m.__path__ = []
|
||||
sys.modules["google.genai"] = m
|
||||
sys.modules["google.genai.types"] = mock.MagicMock()
|
||||
sys.modules["google.genai.errors"] = mock.MagicMock()
|
||||
|
||||
try:
|
||||
import pydantic
|
||||
except ImportError:
|
||||
m_pydantic = mock.MagicMock()
|
||||
|
||||
class MockBaseModel:
|
||||
pass
|
||||
|
||||
m_pydantic.BaseModel = MockBaseModel
|
||||
sys.modules["pydantic"] = m_pydantic
|
||||
|
||||
try:
|
||||
import fastapi
|
||||
import fastapi.openapi.models
|
||||
except ImportError:
|
||||
m_fastapi = mock.MagicMock()
|
||||
m_fastapi.openapi.models = mock.MagicMock()
|
||||
sys.modules["fastapi"] = m_fastapi
|
||||
sys.modules["fastapi.openapi"] = mock.MagicMock()
|
||||
sys.modules["fastapi.openapi.models"] = mock.MagicMock()
|
||||
|
||||
|
||||
from google.adk.integrations.bigquery import search_tool
|
||||
from google.adk.integrations.bigquery.config import BigQueryToolConfig
|
||||
from google.api_core import exceptions as api_exceptions
|
||||
from google.auth.credentials import Credentials
|
||||
from google.cloud import dataplex_v1
|
||||
|
||||
|
||||
def _mock_creds():
|
||||
return mock.create_autospec(Credentials, instance=True)
|
||||
|
||||
|
||||
def _mock_settings(app_name: str | None = "test-app"):
|
||||
return BigQueryToolConfig(application_name=app_name)
|
||||
|
||||
|
||||
def _mock_search_entries_response(results: list[dict[str, Any]]):
|
||||
mock_response = mock.MagicMock(spec=dataplex_v1.SearchEntriesResponse)
|
||||
mock_results = []
|
||||
for r in results:
|
||||
mock_result = mock.create_autospec(
|
||||
dataplex_v1.SearchEntriesResult, instance=True
|
||||
)
|
||||
# Manually attach dataplex_entry since it's not visible in dir() of the proto class
|
||||
mock_entry = mock.create_autospec(dataplex_v1.Entry, instance=True)
|
||||
mock_result.dataplex_entry = mock_entry
|
||||
|
||||
mock_entry.name = r.get("name")
|
||||
mock_entry.entry_type = r.get("entry_type")
|
||||
mock_entry.update_time = r.get("update_time", "2026-01-14T05:00:00Z")
|
||||
|
||||
# Manually attach entry_source since it's not visible in dir() of the proto class
|
||||
mock_source = mock.create_autospec(dataplex_v1.EntrySource, instance=True)
|
||||
mock_entry.entry_source = mock_source
|
||||
|
||||
mock_source.display_name = r.get("display_name")
|
||||
mock_source.resource = r.get("linked_resource")
|
||||
mock_source.description = r.get("description")
|
||||
mock_source.location = r.get("location")
|
||||
mock_results.append(mock_result)
|
||||
mock_response.results = mock_results
|
||||
return mock_response
|
||||
|
||||
|
||||
class TestSearchCatalog(parameterized.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.mock_dataplex_client = mock.create_autospec(
|
||||
dataplex_v1.CatalogServiceClient, instance=True
|
||||
)
|
||||
|
||||
# Patch get_dataplex_catalog_client
|
||||
self.mock_get_dataplex_client = self.enter_context(
|
||||
mock.patch(
|
||||
"google.adk.integrations.bigquery.client.get_dataplex_catalog_client",
|
||||
autospec=True,
|
||||
)
|
||||
)
|
||||
self.mock_get_dataplex_client.return_value = self.mock_dataplex_client
|
||||
self.mock_dataplex_client.__enter__.return_value = self.mock_dataplex_client
|
||||
|
||||
# Patch SearchEntriesRequest
|
||||
self.mock_search_request = self.enter_context(
|
||||
mock.patch(
|
||||
"google.cloud.dataplex_v1.SearchEntriesRequest", autospec=True
|
||||
)
|
||||
)
|
||||
|
||||
def test_search_catalog_success(self):
|
||||
"""Test the successful path of search_catalog."""
|
||||
creds = _mock_creds()
|
||||
settings = _mock_settings()
|
||||
prompt = "customer data"
|
||||
project_id = "test-project"
|
||||
location = "us"
|
||||
|
||||
mock_api_results = [{
|
||||
"name": "entry1",
|
||||
"entry_type": "TABLE",
|
||||
"display_name": "Cust Table",
|
||||
"linked_resource": (
|
||||
"//bigquery.googleapis.com/projects/p/datasets/d/tables/t1"
|
||||
),
|
||||
"description": "Table 1",
|
||||
"location": "us",
|
||||
}]
|
||||
self.mock_dataplex_client.search_entries.return_value = (
|
||||
_mock_search_entries_response(mock_api_results)
|
||||
)
|
||||
|
||||
result = search_tool.search_catalog(
|
||||
prompt=prompt,
|
||||
project_id=project_id,
|
||||
credentials=creds,
|
||||
settings=settings,
|
||||
location=location,
|
||||
)
|
||||
|
||||
with self.subTest("Test result content"):
|
||||
self.assertEqual(result["status"], "SUCCESS")
|
||||
self.assertLen(result["results"], 1)
|
||||
self.assertEqual(result["results"][0]["name"], "entry1")
|
||||
self.assertEqual(result["results"][0]["display_name"], "Cust Table")
|
||||
|
||||
with self.subTest("Test mock calls"):
|
||||
self.mock_get_dataplex_client.assert_called_once_with(
|
||||
credentials=creds, user_agent=["test-app", "search_catalog"]
|
||||
)
|
||||
|
||||
expected_query = (
|
||||
'(customer data) AND projectid="test-project" AND system=BIGQUERY'
|
||||
)
|
||||
self.mock_search_request.assert_called_once_with(
|
||||
name=f"projects/{project_id}/locations/us",
|
||||
query=expected_query,
|
||||
page_size=10,
|
||||
semantic_search=True,
|
||||
)
|
||||
self.mock_dataplex_client.search_entries.assert_called_once_with(
|
||||
request=self.mock_search_request.return_value
|
||||
)
|
||||
|
||||
def test_search_catalog_no_project_id(self):
|
||||
"""Test search_catalog with missing project_id."""
|
||||
result = search_tool.search_catalog(
|
||||
prompt="test",
|
||||
project_id="",
|
||||
credentials=_mock_creds(),
|
||||
settings=_mock_settings(),
|
||||
location="us",
|
||||
)
|
||||
self.assertEqual(result["status"], "ERROR")
|
||||
self.assertIn("project_id must be provided", result["error_details"])
|
||||
self.mock_get_dataplex_client.assert_not_called()
|
||||
|
||||
def test_search_catalog_api_error(self):
|
||||
"""Test search_catalog handling API exceptions."""
|
||||
self.mock_dataplex_client.search_entries.side_effect = (
|
||||
api_exceptions.BadRequest("Invalid query")
|
||||
)
|
||||
|
||||
result = search_tool.search_catalog(
|
||||
prompt="test",
|
||||
project_id="test-project",
|
||||
credentials=_mock_creds(),
|
||||
settings=_mock_settings(),
|
||||
location="us",
|
||||
)
|
||||
self.assertEqual(result["status"], "ERROR")
|
||||
self.assertIn(
|
||||
"Dataplex API Error: 400 Invalid query", result["error_details"]
|
||||
)
|
||||
|
||||
def test_search_catalog_other_exception(self):
|
||||
"""Test search_catalog handling unexpected exceptions."""
|
||||
self.mock_get_dataplex_client.side_effect = Exception(
|
||||
"Something went wrong"
|
||||
)
|
||||
|
||||
result = search_tool.search_catalog(
|
||||
prompt="test",
|
||||
project_id="test-project",
|
||||
credentials=_mock_creds(),
|
||||
settings=_mock_settings(),
|
||||
location="us",
|
||||
)
|
||||
self.assertEqual(result["status"], "ERROR")
|
||||
self.assertIn("Something went wrong", result["error_details"])
|
||||
|
||||
@parameterized.named_parameters(
|
||||
("project_filter", "p", ["proj1"], None, None, 'projectid="proj1"'),
|
||||
(
|
||||
"multi_project_filter",
|
||||
"p",
|
||||
["p1", "p2"],
|
||||
None,
|
||||
None,
|
||||
'(projectid="p1" OR projectid="p2")',
|
||||
),
|
||||
("type_filter", "p", None, None, ["TABLE"], 'type="TABLE"'),
|
||||
(
|
||||
"multi_type_filter",
|
||||
"p",
|
||||
None,
|
||||
None,
|
||||
["TABLE", "DATASET"],
|
||||
'(type="TABLE" OR type="DATASET")',
|
||||
),
|
||||
(
|
||||
"project_and_dataset_filters",
|
||||
"inventory",
|
||||
["proj1", "proj2"],
|
||||
["dsetA"],
|
||||
None,
|
||||
(
|
||||
'(projectid="proj1" OR projectid="proj2") AND'
|
||||
' (linked_resource:"//bigquery.googleapis.com/projects/proj1/datasets/dsetA/*"'
|
||||
' OR linked_resource:"//bigquery.googleapis.com/projects/proj2/datasets/dsetA/*")'
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_search_catalog_query_construction(
|
||||
self, prompt, project_ids, dataset_ids, types, expected_query_part
|
||||
):
|
||||
"""Test different query constructions based on filters."""
|
||||
search_tool.search_catalog(
|
||||
prompt=prompt,
|
||||
project_id="test-project",
|
||||
credentials=_mock_creds(),
|
||||
settings=_mock_settings(),
|
||||
location="us",
|
||||
project_ids_filter=project_ids,
|
||||
dataset_ids_filter=dataset_ids,
|
||||
types_filter=types,
|
||||
)
|
||||
|
||||
self.mock_search_request.assert_called_once()
|
||||
_, kwargs = self.mock_search_request.call_args
|
||||
query = kwargs["query"]
|
||||
|
||||
if prompt:
|
||||
assert f"({prompt})" in query
|
||||
assert "system=BIGQUERY" in query
|
||||
assert expected_query_part in query
|
||||
|
||||
def test_search_catalog_no_app_name(self):
|
||||
"""Test search_catalog when settings.application_name is None."""
|
||||
creds = _mock_creds()
|
||||
settings = _mock_settings(app_name=None)
|
||||
search_tool.search_catalog(
|
||||
prompt="test",
|
||||
project_id="test-project",
|
||||
credentials=creds,
|
||||
settings=settings,
|
||||
location="us",
|
||||
)
|
||||
|
||||
self.mock_get_dataplex_client.assert_called_once_with(
|
||||
credentials=creds, user_agent=[None, "search_catalog"]
|
||||
)
|
||||
|
||||
def test_search_catalog_multi_project_filter_semantic(self):
|
||||
"""Test semantic search with a multi-project filter."""
|
||||
creds = _mock_creds()
|
||||
settings = _mock_settings()
|
||||
prompt = "What datasets store user profiles?"
|
||||
project_id = "main-project"
|
||||
project_filters = ["user-data-proj", "shared-infra-proj"]
|
||||
location = "global"
|
||||
|
||||
self.mock_dataplex_client.search_entries.return_value = (
|
||||
_mock_search_entries_response([])
|
||||
)
|
||||
|
||||
search_tool.search_catalog(
|
||||
prompt=prompt,
|
||||
project_id=project_id,
|
||||
credentials=creds,
|
||||
settings=settings,
|
||||
location=location,
|
||||
project_ids_filter=project_filters,
|
||||
types_filter=["DATASET"],
|
||||
)
|
||||
|
||||
expected_query = (
|
||||
f"({prompt}) AND "
|
||||
'(projectid="user-data-proj" OR projectid="shared-infra-proj") AND '
|
||||
'type="DATASET" AND system=BIGQUERY'
|
||||
)
|
||||
self.mock_search_request.assert_called_once_with(
|
||||
name=f"projects/{project_id}/locations/{location}",
|
||||
query=expected_query,
|
||||
page_size=10,
|
||||
semantic_search=True,
|
||||
)
|
||||
self.mock_dataplex_client.search_entries.assert_called_once()
|
||||
|
||||
def test_search_catalog_natural_language_semantic(self):
|
||||
"""Test natural language prompts with semantic search enabled and check output."""
|
||||
creds = _mock_creds()
|
||||
settings = _mock_settings()
|
||||
prompt = "Find tables about football matches"
|
||||
project_id = "sports-analytics"
|
||||
location = "europe-west1"
|
||||
|
||||
# Mock the results that the API would return for this semantic query
|
||||
mock_api_results = [
|
||||
{
|
||||
"name": (
|
||||
"projects/sports-analytics/locations/europe-west1/entryGroups/@bigquery/entries/fb1"
|
||||
),
|
||||
"display_name": "uk_football_premiership",
|
||||
"entry_type": (
|
||||
"projects/655216118709/locations/global/entryTypes/bigquery-table"
|
||||
),
|
||||
"linked_resource": (
|
||||
"//bigquery.googleapis.com/projects/sports-analytics/datasets/uk/tables/premiership"
|
||||
),
|
||||
"description": "Stats for UK Premier League matches.",
|
||||
"location": "europe-west1",
|
||||
},
|
||||
{
|
||||
"name": (
|
||||
"projects/sports-analytics/locations/europe-west1/entryGroups/@bigquery/entries/fb2"
|
||||
),
|
||||
"display_name": "serie_a_matches",
|
||||
"entry_type": (
|
||||
"projects/655216118709/locations/global/entryTypes/bigquery-table"
|
||||
),
|
||||
"linked_resource": (
|
||||
"//bigquery.googleapis.com/projects/sports-analytics/datasets/italy/tables/serie_a"
|
||||
),
|
||||
"description": "Italian Serie A football results.",
|
||||
"location": "europe-west1",
|
||||
},
|
||||
]
|
||||
self.mock_dataplex_client.search_entries.return_value = (
|
||||
_mock_search_entries_response(mock_api_results)
|
||||
)
|
||||
|
||||
result = search_tool.search_catalog(
|
||||
prompt=prompt,
|
||||
project_id=project_id,
|
||||
credentials=creds,
|
||||
settings=settings,
|
||||
location=location,
|
||||
)
|
||||
|
||||
with self.subTest("Query Construction"):
|
||||
# Assert the request was made as expected
|
||||
expected_query = (
|
||||
f'({prompt}) AND projectid="{project_id}" AND system=BIGQUERY'
|
||||
)
|
||||
self.mock_search_request.assert_called_once_with(
|
||||
name=f"projects/{project_id}/locations/{location}",
|
||||
query=expected_query,
|
||||
page_size=10,
|
||||
semantic_search=True,
|
||||
)
|
||||
self.mock_dataplex_client.search_entries.assert_called_once()
|
||||
|
||||
with self.subTest("Response Processing"):
|
||||
# Assert the output is processed correctly
|
||||
self.assertEqual(result["status"], "SUCCESS")
|
||||
self.assertLen(result["results"], 2)
|
||||
self.assertEqual(
|
||||
result["results"][0]["display_name"], "uk_football_premiership"
|
||||
)
|
||||
self.assertEqual(result["results"][1]["display_name"], "serie_a_matches")
|
||||
self.assertIn("UK Premier League", result["results"][0]["description"])
|
||||
|
||||
def test_search_catalog_default_location(self):
|
||||
"""Test search_catalog fallback to global location when None is provided."""
|
||||
creds = _mock_creds()
|
||||
settings = _mock_settings()
|
||||
# settings.location is None by default
|
||||
|
||||
self.mock_dataplex_client.search_entries.return_value = (
|
||||
_mock_search_entries_response([])
|
||||
)
|
||||
|
||||
search_tool.search_catalog(
|
||||
prompt="test",
|
||||
project_id="test-project",
|
||||
credentials=creds,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
self.mock_search_request.assert_called_once()
|
||||
_, kwargs = self.mock_search_request.call_args
|
||||
name_arg = kwargs["name"]
|
||||
self.assertIn("locations/global", name_arg)
|
||||
|
||||
def test_search_catalog_settings_location(self):
|
||||
"""Test search_catalog uses settings.location when provided."""
|
||||
creds = _mock_creds()
|
||||
settings = BigQueryToolConfig(location="eu")
|
||||
|
||||
self.mock_dataplex_client.search_entries.return_value = (
|
||||
_mock_search_entries_response([])
|
||||
)
|
||||
|
||||
search_tool.search_catalog(
|
||||
prompt="test",
|
||||
project_id="test-project",
|
||||
credentials=creds,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
self.mock_search_request.assert_called_once()
|
||||
_, kwargs = self.mock_search_request.call_args
|
||||
name_arg = kwargs["name"]
|
||||
self.assertIn("locations/eu", name_arg)
|
||||
@@ -0,0 +1,143 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
|
||||
from google.adk.features._feature_registry import _WARNED_FEATURES
|
||||
from google.adk.integrations.bigquery.config import BigQueryToolConfig
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_warned_features():
|
||||
"""Reset warned features before each test."""
|
||||
_WARNED_FEATURES.clear()
|
||||
|
||||
|
||||
def test_bigquery_tool_config_invalid_property():
|
||||
"""Test BigQueryToolConfig raises exception when setting invalid property."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
):
|
||||
BigQueryToolConfig(non_existent_field="some value")
|
||||
|
||||
|
||||
def test_bigquery_tool_config_invalid_application_name():
|
||||
"""Test BigQueryToolConfig raises exception with invalid application name."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Application name should not contain spaces.",
|
||||
):
|
||||
BigQueryToolConfig(application_name="my agent")
|
||||
|
||||
|
||||
def test_bigquery_tool_config_max_query_result_rows_default():
|
||||
"""Test BigQueryToolConfig max_query_result_rows default value."""
|
||||
config = BigQueryToolConfig()
|
||||
assert config.max_query_result_rows == 50
|
||||
|
||||
|
||||
def test_bigquery_tool_config_max_query_result_rows_custom():
|
||||
"""Test BigQueryToolConfig max_query_result_rows custom value."""
|
||||
config = BigQueryToolConfig(max_query_result_rows=100)
|
||||
assert config.max_query_result_rows == 100
|
||||
|
||||
|
||||
def test_bigquery_tool_config_valid_maximum_bytes_billed():
|
||||
"""Test BigQueryToolConfig raises exception with valid max bytes billed."""
|
||||
config = BigQueryToolConfig(maximum_bytes_billed=10_485_760)
|
||||
assert config.maximum_bytes_billed == 10_485_760
|
||||
|
||||
|
||||
def test_bigquery_tool_config_invalid_maximum_bytes_billed():
|
||||
"""Test BigQueryToolConfig raises exception with invalid max bytes billed."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"In BigQuery on-demand pricing, charges are rounded up to the nearest"
|
||||
" MB, with a minimum 10 MB data processed per table referenced by the"
|
||||
" query, and with a minimum 10 MB data processed per query. So"
|
||||
" max_bytes_billed must be set >=10485760."
|
||||
),
|
||||
):
|
||||
BigQueryToolConfig(maximum_bytes_billed=10_485_759)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"labels",
|
||||
[
|
||||
pytest.param(
|
||||
{"environment": "test", "team": "data"},
|
||||
id="valid-labels",
|
||||
),
|
||||
pytest.param(
|
||||
{},
|
||||
id="empty-labels",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
id="none-labels",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_bigquery_tool_config_valid_labels(labels):
|
||||
"""Test BigQueryToolConfig accepts valid labels."""
|
||||
config = BigQueryToolConfig(job_labels=labels)
|
||||
assert config.job_labels == labels
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("labels", "message"),
|
||||
[
|
||||
pytest.param(
|
||||
"invalid",
|
||||
"Input should be a valid dictionary",
|
||||
id="invalid-type",
|
||||
),
|
||||
pytest.param(
|
||||
{123: "value"},
|
||||
"Input should be a valid string",
|
||||
id="non-str-key",
|
||||
),
|
||||
pytest.param(
|
||||
{"key": 123},
|
||||
"Input should be a valid string",
|
||||
id="non-str-value",
|
||||
),
|
||||
pytest.param(
|
||||
{"": "value"},
|
||||
"Label keys cannot be empty",
|
||||
id="empty-label-key",
|
||||
),
|
||||
pytest.param(
|
||||
{"adk-bigquery-test": "value"},
|
||||
'Label key cannot start with "adk-bigquery-"',
|
||||
id="internal-label-key",
|
||||
),
|
||||
pytest.param(
|
||||
{f"key_{i}": "value" for i in range(21)},
|
||||
"Only up to 20 job labels can be provided",
|
||||
id="too-many-labels",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_bigquery_tool_config_invalid_labels(labels, message):
|
||||
"""Test BigQueryToolConfig raises an exception with invalid labels."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=message,
|
||||
):
|
||||
BigQueryToolConfig(job_labels=labels)
|
||||
@@ -0,0 +1,134 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from google.adk.integrations.bigquery import BigQueryCredentialsConfig
|
||||
from google.adk.integrations.bigquery import BigQueryToolset
|
||||
from google.adk.integrations.bigquery.config import BigQueryToolConfig
|
||||
from google.adk.tools.google_tool import GoogleTool
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bigquery_toolset_tools_default():
|
||||
"""Test default BigQuery toolset.
|
||||
|
||||
This test verifies the behavior of the BigQuery toolset when no filter is
|
||||
specified.
|
||||
"""
|
||||
credentials_config = BigQueryCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = BigQueryToolset(
|
||||
credentials_config=credentials_config, bigquery_tool_config=None
|
||||
)
|
||||
# Verify that the tool config is initialized to default values.
|
||||
assert isinstance(toolset._tool_settings, BigQueryToolConfig) # pylint: disable=protected-access
|
||||
assert toolset._tool_settings.__dict__ == BigQueryToolConfig().__dict__ # pylint: disable=protected-access
|
||||
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == 11
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set([
|
||||
"list_dataset_ids",
|
||||
"get_dataset_info",
|
||||
"list_table_ids",
|
||||
"get_table_info",
|
||||
"get_job_info",
|
||||
"execute_sql",
|
||||
"ask_data_insights",
|
||||
"forecast",
|
||||
"analyze_contribution",
|
||||
"detect_anomalies",
|
||||
"search_catalog",
|
||||
])
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"selected_tools",
|
||||
[
|
||||
pytest.param([], id="None"),
|
||||
pytest.param(
|
||||
["list_dataset_ids", "get_dataset_info"], id="dataset-metadata"
|
||||
),
|
||||
pytest.param(["list_table_ids", "get_table_info"], id="table-metadata"),
|
||||
pytest.param(["execute_sql"], id="query"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_bigquery_toolset_tools_selective(selected_tools):
|
||||
"""Test BigQuery toolset with filter.
|
||||
|
||||
This test verifies the behavior of the BigQuery toolset when filter is
|
||||
specified. A use case for this would be when the agent builder wants to
|
||||
use only a subset of the tools provided by the toolset.
|
||||
"""
|
||||
credentials_config = BigQueryCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = BigQueryToolset(
|
||||
credentials_config=credentials_config, tool_filter=selected_tools
|
||||
)
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == len(selected_tools)
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set(selected_tools)
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selected_tools", "returned_tools"),
|
||||
[
|
||||
pytest.param(["unknown"], [], id="all-unknown"),
|
||||
pytest.param(
|
||||
["unknown", "execute_sql"],
|
||||
["execute_sql"],
|
||||
id="mixed-known-unknown",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_bigquery_toolset_unknown_tool(selected_tools, returned_tools):
|
||||
"""Test BigQuery toolset with filter.
|
||||
|
||||
This test verifies the behavior of the BigQuery toolset when filter is
|
||||
specified with an unknown tool.
|
||||
"""
|
||||
credentials_config = BigQueryCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
|
||||
toolset = BigQueryToolset(
|
||||
credentials_config=credentials_config, tool_filter=selected_tools
|
||||
)
|
||||
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == len(returned_tools)
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set(returned_tools)
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
description: "Tests a full, realistic stream about finding the penguin island with the highest body mass."
|
||||
|
||||
user_question: "Penguins on which island have the highest average body mass?"
|
||||
|
||||
mock_api_stream: |
|
||||
[{
|
||||
"timestamp": "2025-07-17T17:25:28.231Z",
|
||||
"systemMessage": {
|
||||
"text": {
|
||||
"parts": [
|
||||
"Penguins on which island have the highest average body mass?"
|
||||
],
|
||||
"textType": "THOUGHT"
|
||||
}
|
||||
}
|
||||
}
|
||||
,
|
||||
{
|
||||
"timestamp": "2025-07-17T17:25:31.171Z",
|
||||
"systemMessage": {
|
||||
"data": {
|
||||
"generatedSql": "SELECT island, AVG(body_mass_g) AS average_body_mass\nFROM `bigframes-dev-perf`.`bigframes_testing_eu`.`penguins`\nGROUP BY island;"
|
||||
}
|
||||
}
|
||||
}
|
||||
,
|
||||
{
|
||||
"timestamp": "2025-07-17T17:25:32.664Z",
|
||||
"systemMessage": {
|
||||
"data": {
|
||||
"result": {
|
||||
"data": [
|
||||
{
|
||||
"island": "Biscoe",
|
||||
"average_body_mass": "4716.017964071853"
|
||||
},
|
||||
{
|
||||
"island": "Dream",
|
||||
"average_body_mass": "3712.9032258064512"
|
||||
},
|
||||
{
|
||||
"island": "Torgersen",
|
||||
"average_body_mass": "3706.3725490196075"
|
||||
}
|
||||
],
|
||||
"name": "average_body_mass_by_island",
|
||||
"schema": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "island",
|
||||
"type": "STRING",
|
||||
"mode": "NULLABLE"
|
||||
},
|
||||
{
|
||||
"name": "average_body_mass",
|
||||
"type": "FLOAT",
|
||||
"mode": "NULLABLE"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
,
|
||||
{
|
||||
"timestamp": "2025-07-17T17:25:40.018Z",
|
||||
"systemMessage": {
|
||||
"text": {
|
||||
"parts": [
|
||||
"Penguins on Biscoe island have the highest average body mass, with an average of 4716.02g."
|
||||
],
|
||||
"textType": "FINAL_RESPONSE"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
expected_output:
|
||||
- text:
|
||||
parts:
|
||||
- 'Penguins on which island have the highest average body mass?'
|
||||
textType: THOUGHT
|
||||
- data:
|
||||
generatedSql: "SELECT island, AVG(body_mass_g) AS average_body_mass\nFROM `bigframes-dev-perf`.`bigframes_testing_eu`.`penguins`\nGROUP BY island;"
|
||||
- Data Retrieved:
|
||||
headers:
|
||||
- island
|
||||
- average_body_mass
|
||||
rows:
|
||||
- - Biscoe
|
||||
- '4716.017964071853'
|
||||
- - Dream
|
||||
- '3712.9032258064512'
|
||||
- - Torgersen
|
||||
- '3706.3725490196075'
|
||||
summary: Showing all 3 rows.
|
||||
- text:
|
||||
parts:
|
||||
- "Penguins on Biscoe island have the highest average body mass, with an average of 4716.02g."
|
||||
textType: FINAL_RESPONSE
|
||||
@@ -0,0 +1,182 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.agents.base_agent import BaseAgent
|
||||
from google.adk.agents.invocation_context import InvocationContext
|
||||
from google.adk.code_executors.code_execution_utils import CodeExecutionInput
|
||||
from google.adk.code_executors.code_execution_utils import CodeExecutionResult
|
||||
from google.adk.integrations.cloud_run import CloudRunSandboxCodeExecutor
|
||||
from google.adk.sessions.base_session_service import BaseSessionService
|
||||
from google.adk.sessions.session import Session
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_invocation_context() -> InvocationContext:
|
||||
"""Provides a mock InvocationContext."""
|
||||
mock_agent = MagicMock(spec=BaseAgent)
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_session_service = MagicMock(spec=BaseSessionService)
|
||||
return InvocationContext(
|
||||
invocation_id="test_invocation",
|
||||
agent=mock_agent,
|
||||
session=mock_session,
|
||||
session_service=mock_session_service,
|
||||
)
|
||||
|
||||
|
||||
class TestCloudRunSandboxCodeExecutor:
|
||||
|
||||
def test_init_default(self):
|
||||
executor = CloudRunSandboxCodeExecutor()
|
||||
assert not executor.stateful
|
||||
assert not executor.optimize_data_file
|
||||
assert executor.sandbox_bin == "/usr/local/gcp/bin/sandbox"
|
||||
assert not executor.allow_egress
|
||||
|
||||
def test_init_stateful_raises_error(self):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Cannot set `stateful=True` in CloudRunSandboxCodeExecutor.",
|
||||
):
|
||||
CloudRunSandboxCodeExecutor(stateful=True)
|
||||
|
||||
def test_init_optimize_data_file_raises_error(self):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Cannot set `optimize_data_file=True` in"
|
||||
" CloudRunSandboxCodeExecutor."
|
||||
),
|
||||
):
|
||||
CloudRunSandboxCodeExecutor(optimize_data_file=True)
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_execute_code_success(
|
||||
self, mock_run, mock_invocation_context: InvocationContext
|
||||
):
|
||||
# Setup mock subprocess.run response
|
||||
mock_response = MagicMock()
|
||||
mock_response.stdout = "hello world\n"
|
||||
mock_response.stderr = ""
|
||||
mock_response.returncode = 0
|
||||
mock_run.return_value = mock_response
|
||||
|
||||
executor = CloudRunSandboxCodeExecutor()
|
||||
code_input = CodeExecutionInput(code='print("hello world")')
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
assert isinstance(result, CodeExecutionResult)
|
||||
assert result.stdout == "hello world\n"
|
||||
assert result.stderr == ""
|
||||
assert result.output_files == []
|
||||
|
||||
# Verify subprocess.run was called with correct arguments
|
||||
expected_python = sys.executable or "python3"
|
||||
mock_run.assert_called_once_with(
|
||||
["/usr/local/gcp/bin/sandbox", "do", expected_python],
|
||||
input='print("hello world")',
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=None,
|
||||
check=False,
|
||||
)
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_execute_code_with_egress_and_custom_bin(
|
||||
self, mock_run, mock_invocation_context: InvocationContext
|
||||
):
|
||||
mock_response = MagicMock()
|
||||
mock_response.stdout = "egress success\n"
|
||||
mock_response.stderr = ""
|
||||
mock_response.returncode = 0
|
||||
mock_run.return_value = mock_response
|
||||
|
||||
executor = CloudRunSandboxCodeExecutor(
|
||||
sandbox_bin="/usr/bin/custom-sandbox",
|
||||
allow_egress=True,
|
||||
timeout_seconds=10,
|
||||
)
|
||||
code_input = CodeExecutionInput(code="import requests; print('ok')")
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
assert result.stdout == "egress success\n"
|
||||
|
||||
expected_python = sys.executable or "python3"
|
||||
mock_run.assert_called_once_with(
|
||||
["/usr/bin/custom-sandbox", "do", "--allow-egress", expected_python],
|
||||
input="import requests; print('ok')",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_execute_code_with_error(
|
||||
self, mock_run, mock_invocation_context: InvocationContext
|
||||
):
|
||||
mock_response = MagicMock()
|
||||
mock_response.stdout = ""
|
||||
mock_response.stderr = "Traceback ... ValueError: Test error\n"
|
||||
mock_response.returncode = 1
|
||||
mock_run.return_value = mock_response
|
||||
|
||||
executor = CloudRunSandboxCodeExecutor()
|
||||
code_input = CodeExecutionInput(code='raise ValueError("Test error")')
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
assert result.stdout == ""
|
||||
assert "ValueError: Test error" in result.stderr
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_execute_code_timeout(
|
||||
self, mock_run, mock_invocation_context: InvocationContext
|
||||
):
|
||||
import subprocess
|
||||
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(
|
||||
cmd=["sandbox", "do", "python3"],
|
||||
timeout=5,
|
||||
output="partial stdout",
|
||||
stderr="partial stderr",
|
||||
)
|
||||
|
||||
executor = CloudRunSandboxCodeExecutor(timeout_seconds=5)
|
||||
code_input = CodeExecutionInput(code="import time\ntime.sleep(10)")
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
assert result.stdout == "partial stdout"
|
||||
assert result.stderr == "partial stderr"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_execute_code_binary_not_found(
|
||||
self, mock_run, mock_invocation_context: InvocationContext
|
||||
):
|
||||
mock_run.side_effect = FileNotFoundError(
|
||||
"[Errno 2] No such file or directory: 'sandbox'"
|
||||
)
|
||||
|
||||
executor = CloudRunSandboxCodeExecutor()
|
||||
code_input = CodeExecutionInput(code='print("hello")')
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
assert result.stdout == ""
|
||||
assert (
|
||||
'Sandbox binary "/usr/local/gcp/bin/sandbox" not found' in result.stderr
|
||||
)
|
||||
@@ -0,0 +1,217 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# Skip the module when the optional crewai dependency is not installed. Guard on
|
||||
# the third-party dep itself rather than the adk wrapper, so a real import bug in
|
||||
# crewai_tool surfaces as a failure instead of being silently skipped.
|
||||
pytest.importorskip(
|
||||
"crewai.tools", reason="Requires crewai (google-adk[extensions])"
|
||||
)
|
||||
|
||||
from google.adk.agents.context import Context
|
||||
from google.adk.agents.invocation_context import InvocationContext
|
||||
from google.adk.integrations.crewai import CrewaiTool
|
||||
from google.adk.sessions.session import Session
|
||||
from google.adk.tools.tool_context import ToolContext
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tool_context() -> ToolContext:
|
||||
"""Fixture that provides a mock ToolContext for testing."""
|
||||
mock_invocation_context = MagicMock(spec=InvocationContext)
|
||||
mock_invocation_context._state_schema = None
|
||||
mock_invocation_context.session = MagicMock(spec=Session)
|
||||
mock_invocation_context.session.state = MagicMock()
|
||||
return ToolContext(invocation_context=mock_invocation_context)
|
||||
|
||||
|
||||
def _simple_crewai_tool(*args, **kwargs):
|
||||
"""Simple CrewAI-style tool that accepts any keyword arguments."""
|
||||
return {
|
||||
"search_query": kwargs.get("search_query"),
|
||||
"other_param": kwargs.get("other_param"),
|
||||
}
|
||||
|
||||
|
||||
def _crewai_tool_with_context(tool_context: ToolContext, *args, **kwargs):
|
||||
"""CrewAI tool with explicit tool_context parameter."""
|
||||
return {
|
||||
"search_query": kwargs.get("search_query"),
|
||||
"tool_context_present": bool(tool_context),
|
||||
}
|
||||
|
||||
|
||||
def _crewai_tool_with_context_type(ctx: Context, *args, **kwargs):
|
||||
"""CrewAI tool with Context type annotation."""
|
||||
return {
|
||||
"search_query": kwargs.get("search_query"),
|
||||
"context_present": bool(ctx),
|
||||
}
|
||||
|
||||
|
||||
class MockCrewaiBaseTool:
|
||||
"""Mock CrewAI BaseTool for testing."""
|
||||
|
||||
def __init__(self, run_func, name="mock_tool", description="Mock tool"):
|
||||
self.run = run_func
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.args_schema = MagicMock()
|
||||
self.args_schema.model_json_schema.return_value = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"search_query": {"type": "string", "description": "Search query"}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_crewai_tool_initialization():
|
||||
"""Test CrewaiTool initialization with various parameters."""
|
||||
mock_crewai_tool = MockCrewaiBaseTool(_simple_crewai_tool)
|
||||
|
||||
# Test with custom name and description
|
||||
tool = CrewaiTool(
|
||||
mock_crewai_tool,
|
||||
name="custom_search_tool",
|
||||
description="Custom search tool description",
|
||||
)
|
||||
|
||||
assert tool.name == "custom_search_tool"
|
||||
assert tool.description == "Custom search tool description"
|
||||
assert tool.tool == mock_crewai_tool
|
||||
|
||||
|
||||
def test_crewai_tool_initialization_with_tool_defaults():
|
||||
"""Test CrewaiTool initialization using tool's default name and description."""
|
||||
mock_crewai_tool = MockCrewaiBaseTool(
|
||||
_simple_crewai_tool,
|
||||
name="Serper Dev Tool",
|
||||
description="Search the internet with Serper",
|
||||
)
|
||||
|
||||
# Test with empty name and description (should use tool defaults)
|
||||
tool = CrewaiTool(mock_crewai_tool, name="", description="")
|
||||
|
||||
assert (
|
||||
tool.name == "serper_dev_tool"
|
||||
) # Spaces replaced with underscores, lowercased
|
||||
assert tool.description == "Search the internet with Serper"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crewai_tool_basic_functionality(mock_tool_context):
|
||||
"""Test basic CrewaiTool functionality with **kwargs parameter passing."""
|
||||
mock_crewai_tool = MockCrewaiBaseTool(_simple_crewai_tool)
|
||||
tool = CrewaiTool(mock_crewai_tool, name="test_tool", description="Test tool")
|
||||
|
||||
# Test that **kwargs parameters are passed through correctly
|
||||
result = await tool.run_async(
|
||||
args={"search_query": "test query", "other_param": "test value"},
|
||||
tool_context=mock_tool_context,
|
||||
)
|
||||
|
||||
assert result["search_query"] == "test query"
|
||||
assert result["other_param"] == "test value"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crewai_tool_with_tool_context(mock_tool_context):
|
||||
"""Test CrewaiTool with a tool that has explicit tool_context parameter."""
|
||||
mock_crewai_tool = MockCrewaiBaseTool(_crewai_tool_with_context)
|
||||
tool = CrewaiTool(
|
||||
mock_crewai_tool, name="context_tool", description="Context tool"
|
||||
)
|
||||
|
||||
# Test that tool_context is properly injected
|
||||
result = await tool.run_async(
|
||||
args={"search_query": "test query"},
|
||||
tool_context=mock_tool_context,
|
||||
)
|
||||
|
||||
assert result["search_query"] == "test query"
|
||||
assert result["tool_context_present"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crewai_tool_parameter_filtering(mock_tool_context):
|
||||
"""Test that CrewaiTool filters parameters for non-**kwargs functions."""
|
||||
|
||||
def explicit_params_func(arg1: str, arg2: int):
|
||||
"""Function with explicit parameters (no **kwargs)."""
|
||||
return {"arg1": arg1, "arg2": arg2}
|
||||
|
||||
mock_crewai_tool = MockCrewaiBaseTool(explicit_params_func)
|
||||
tool = CrewaiTool(
|
||||
mock_crewai_tool, name="explicit_tool", description="Explicit tool"
|
||||
)
|
||||
|
||||
# Test that unexpected parameters are filtered out
|
||||
result = await tool.run_async(
|
||||
args={
|
||||
"arg1": "test",
|
||||
"arg2": 42,
|
||||
"unexpected_param": "should_be_filtered",
|
||||
},
|
||||
tool_context=mock_tool_context,
|
||||
)
|
||||
|
||||
assert result == {"arg1": "test", "arg2": 42}
|
||||
# Verify unexpected parameter was filtered out
|
||||
assert "unexpected_param" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crewai_tool_get_declaration():
|
||||
"""Test that CrewaiTool properly builds function declarations."""
|
||||
mock_crewai_tool = MockCrewaiBaseTool(_simple_crewai_tool)
|
||||
tool = CrewaiTool(mock_crewai_tool, name="test_tool", description="Test tool")
|
||||
|
||||
# Test function declaration generation
|
||||
declaration = tool._get_declaration()
|
||||
|
||||
# Verify the declaration object structure and content
|
||||
assert declaration is not None
|
||||
assert declaration.name == "test_tool"
|
||||
assert declaration.description == "Test tool"
|
||||
assert declaration.parameters is not None
|
||||
|
||||
# Verify that the args_schema was used to build the declaration
|
||||
mock_crewai_tool.args_schema.model_json_schema.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crewai_tool_with_context_type_annotation(mock_tool_context):
|
||||
"""Test CrewaiTool with Context type annotation and custom parameter name."""
|
||||
mock_crewai_tool = MockCrewaiBaseTool(_crewai_tool_with_context_type)
|
||||
tool = CrewaiTool(
|
||||
mock_crewai_tool,
|
||||
name="context_type_tool",
|
||||
description="Context type tool",
|
||||
)
|
||||
|
||||
# Verify the context parameter is detected by type
|
||||
assert tool._context_param_name == "ctx"
|
||||
|
||||
# Test that context is properly injected
|
||||
result = await tool.run_async(
|
||||
args={"search_query": "test query"},
|
||||
tool_context=mock_tool_context,
|
||||
)
|
||||
|
||||
assert result["search_query"] == "test query"
|
||||
assert result["context_present"]
|
||||
@@ -0,0 +1,242 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for DaytonaEnvironment."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import daytona
|
||||
from daytona import CreateSandboxFromImageParams
|
||||
from daytona import CreateSandboxFromSnapshotParams
|
||||
from daytona import DaytonaError
|
||||
from daytona import DaytonaNotFoundError
|
||||
from google.adk.integrations.daytona._daytona_environment import DaytonaEnvironment
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_sandbox() -> mock.MagicMock:
|
||||
"""Build a mock AsyncSandbox with async method stubs."""
|
||||
sandbox = mock.MagicMock(name="AsyncSandbox")
|
||||
sandbox.delete = mock.AsyncMock()
|
||||
sandbox.process.exec = mock.AsyncMock()
|
||||
sandbox.fs.download_file = mock.AsyncMock()
|
||||
sandbox.fs.upload_file = mock.AsyncMock()
|
||||
sandbox.fs.create_folder = mock.AsyncMock()
|
||||
sandbox.refresh_activity = mock.AsyncMock()
|
||||
return sandbox
|
||||
|
||||
|
||||
@pytest.fixture(name="sandbox")
|
||||
def _sandbox() -> mock.MagicMock:
|
||||
return _make_sandbox()
|
||||
|
||||
|
||||
@pytest.fixture(name="daytona_patch")
|
||||
def _daytona_patch(sandbox: mock.MagicMock):
|
||||
"""Patch AsyncDaytona to return a mock client."""
|
||||
mock_client = mock.MagicMock(name="AsyncDaytona")
|
||||
mock_client.create = mock.AsyncMock(return_value=sandbox)
|
||||
|
||||
with mock.patch.object(daytona, "AsyncDaytona", autospec=True) as mock_class:
|
||||
mock_class.return_value = mock_client
|
||||
yield mock_class
|
||||
|
||||
|
||||
async def test_initialize_creates_sandbox(daytona_patch, sandbox):
|
||||
env = DaytonaEnvironment(image="custom-image", env_vars={"A": "1"})
|
||||
assert env.is_initialized is False
|
||||
await env.initialize()
|
||||
assert env.is_initialized is True
|
||||
|
||||
daytona_patch.assert_called_once()
|
||||
client = daytona_patch.return_value
|
||||
client.create.assert_awaited_once()
|
||||
|
||||
args, _ = client.create.call_args
|
||||
params = args[0]
|
||||
assert isinstance(params, CreateSandboxFromImageParams)
|
||||
assert params.image == "custom-image"
|
||||
assert params.env_vars == {"A": "1"}
|
||||
assert params.auto_stop_interval == 5
|
||||
assert params.auto_delete_interval == 0
|
||||
assert env._sandbox is sandbox
|
||||
|
||||
|
||||
async def test_initialize_creates_sandbox_default(daytona_patch, sandbox):
|
||||
env = DaytonaEnvironment(env_vars={"B": "2"})
|
||||
await env.initialize()
|
||||
|
||||
daytona_patch.assert_called_once()
|
||||
client = daytona_patch.return_value
|
||||
client.create.assert_awaited_once()
|
||||
|
||||
args, _ = client.create.call_args
|
||||
params = args[0]
|
||||
assert isinstance(params, CreateSandboxFromSnapshotParams)
|
||||
assert params.language == "python"
|
||||
assert params.env_vars == {"B": "2"}
|
||||
assert params.auto_stop_interval == 5
|
||||
assert params.auto_delete_interval == 0
|
||||
assert env._sandbox is sandbox
|
||||
|
||||
|
||||
async def test_initialize_is_idempotent(daytona_patch, sandbox):
|
||||
env = DaytonaEnvironment()
|
||||
await env.initialize()
|
||||
await env.initialize()
|
||||
client = daytona_patch.return_value
|
||||
client.create.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_close_deletes_sandbox_and_is_idempotent(daytona_patch, sandbox):
|
||||
env = DaytonaEnvironment()
|
||||
await env.initialize()
|
||||
assert env.is_initialized is True
|
||||
await env.close()
|
||||
sandbox.delete.assert_awaited_once()
|
||||
assert env._sandbox is None
|
||||
assert env.is_initialized is False
|
||||
|
||||
# Second close is a no-op.
|
||||
await env.close()
|
||||
sandbox.delete.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_working_dir_requires_initialize():
|
||||
env = DaytonaEnvironment()
|
||||
with pytest.raises(RuntimeError):
|
||||
_ = env.working_dir
|
||||
|
||||
|
||||
async def test_execute_before_initialize_raises():
|
||||
env = DaytonaEnvironment()
|
||||
with pytest.raises(RuntimeError):
|
||||
await env.execute("echo hi")
|
||||
|
||||
|
||||
async def test_execute_success(daytona_patch, sandbox):
|
||||
class MockArtifacts:
|
||||
stdout = "out"
|
||||
|
||||
class MockResponse:
|
||||
exit_code = 0
|
||||
artifacts = MockArtifacts()
|
||||
|
||||
sandbox.process.exec.return_value = MockResponse()
|
||||
|
||||
env = DaytonaEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
result = await env.execute("echo out")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout == "out"
|
||||
assert result.stderr == ""
|
||||
assert result.timed_out is False
|
||||
sandbox.refresh_activity.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_execute_timeout(daytona_patch, sandbox):
|
||||
# Simulate a Daytona timeout error
|
||||
sandbox.process.exec.side_effect = DaytonaError("timeout occurred")
|
||||
env = DaytonaEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
result = await env.execute("sleep 999")
|
||||
|
||||
assert result.timed_out is True
|
||||
|
||||
|
||||
async def test_read_file_returns_bytes(daytona_patch, sandbox):
|
||||
sandbox.fs.download_file.return_value = b"data"
|
||||
env = DaytonaEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
data = await env.read_file("notes.txt")
|
||||
|
||||
assert data == b"data"
|
||||
sandbox.fs.download_file.assert_awaited_once_with("/workspaces/notes.txt")
|
||||
sandbox.refresh_activity.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_read_file_absolute_path_passthrough(daytona_patch, sandbox):
|
||||
sandbox.fs.download_file.return_value = b"x"
|
||||
env = DaytonaEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
await env.read_file("/etc/hostname")
|
||||
|
||||
sandbox.fs.download_file.assert_awaited_once_with("/etc/hostname")
|
||||
|
||||
|
||||
async def test_read_file_missing_raises(daytona_patch, sandbox):
|
||||
sandbox.fs.download_file.return_value = None
|
||||
env = DaytonaEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await env.read_file("missing.txt")
|
||||
|
||||
|
||||
async def test_write_file_resolves_relative_path(daytona_patch, sandbox):
|
||||
env = DaytonaEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
await env.write_file("sub/out.txt", "hello")
|
||||
sandbox.refresh_activity.assert_awaited_once()
|
||||
|
||||
sandbox.fs.upload_file.assert_awaited_once_with(
|
||||
b"hello", "/workspaces/sub/out.txt"
|
||||
)
|
||||
|
||||
|
||||
async def test_initialize_propagates_api_key_and_url(daytona_patch, sandbox):
|
||||
from daytona import DaytonaConfig
|
||||
|
||||
env = DaytonaEnvironment(api_key="my-key", api_url="my-url")
|
||||
await env.initialize()
|
||||
|
||||
daytona_patch.assert_called_once()
|
||||
_, kwargs = daytona_patch.call_args
|
||||
config = kwargs.get("config")
|
||||
assert isinstance(config, DaytonaConfig)
|
||||
assert config.api_key == "my-key"
|
||||
assert config.api_url == "my-url"
|
||||
|
||||
|
||||
async def test_write_file_creates_parent_directory(daytona_patch, sandbox):
|
||||
env = DaytonaEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
await env.write_file("sub/nested/file.txt", "content")
|
||||
|
||||
sandbox.fs.create_folder.assert_has_calls([
|
||||
mock.call("/workspaces", mode="755"),
|
||||
mock.call("/workspaces/sub", mode="755"),
|
||||
mock.call("/workspaces/sub/nested", mode="755"),
|
||||
])
|
||||
sandbox.fs.upload_file.assert_awaited_once_with(
|
||||
b"content", "/workspaces/sub/nested/file.txt"
|
||||
)
|
||||
|
||||
|
||||
async def test_read_file_raises_file_not_found_on_daytona_not_found(
|
||||
daytona_patch, sandbox
|
||||
):
|
||||
sandbox.fs.download_file.side_effect = DaytonaNotFoundError("not found")
|
||||
env = DaytonaEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await env.read_file("missing.txt")
|
||||
@@ -0,0 +1,224 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for E2BEnvironment."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from e2b import CommandExitException
|
||||
from e2b import CommandResult
|
||||
from e2b import FileNotFoundException
|
||||
from e2b import TimeoutException
|
||||
from google.adk.integrations.e2b._e2b_environment import E2BEnvironment
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_sandbox(*, running: bool = True) -> mock.MagicMock:
|
||||
"""Build a mock AsyncSandbox with async method stubs."""
|
||||
sandbox = mock.MagicMock(name='AsyncSandbox')
|
||||
sandbox.is_running = mock.AsyncMock(return_value=running)
|
||||
sandbox.set_timeout = mock.AsyncMock()
|
||||
sandbox.kill = mock.AsyncMock(return_value=True)
|
||||
sandbox.commands.run = mock.AsyncMock()
|
||||
sandbox.files.read = mock.AsyncMock()
|
||||
sandbox.files.write = mock.AsyncMock()
|
||||
return sandbox
|
||||
|
||||
|
||||
@pytest.fixture(name='sandbox')
|
||||
def _sandbox() -> mock.MagicMock:
|
||||
return _make_sandbox()
|
||||
|
||||
|
||||
@pytest.fixture(name='create_patch')
|
||||
def _create_patch(sandbox: mock.MagicMock):
|
||||
"""Patch AsyncSandbox.create to return the mock sandbox."""
|
||||
with mock.patch(
|
||||
'e2b.AsyncSandbox.create', new=mock.AsyncMock(return_value=sandbox)
|
||||
) as create:
|
||||
yield create
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_creates_sandbox(create_patch, sandbox):
|
||||
env = E2BEnvironment(image='custom', timeout=120, env_vars={'A': '1'})
|
||||
assert env.is_initialized is False
|
||||
await env.initialize()
|
||||
assert env.is_initialized is True
|
||||
|
||||
create_patch.assert_awaited_once()
|
||||
_, kwargs = create_patch.call_args
|
||||
assert kwargs['template'] == 'custom'
|
||||
assert kwargs['timeout'] == 120
|
||||
assert kwargs['envs'] == {'A': '1'}
|
||||
assert env._sandbox is sandbox
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_is_idempotent(create_patch, sandbox):
|
||||
env = E2BEnvironment()
|
||||
await env.initialize()
|
||||
await env.initialize()
|
||||
create_patch.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_kills_sandbox_and_is_idempotent(create_patch, sandbox):
|
||||
env = E2BEnvironment()
|
||||
await env.initialize()
|
||||
assert env.is_initialized is True
|
||||
await env.close()
|
||||
sandbox.kill.assert_awaited_once()
|
||||
assert env._sandbox is None
|
||||
assert env.is_initialized is False
|
||||
# Second close is a no-op.
|
||||
await env.close()
|
||||
sandbox.kill.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_working_dir_requires_initialize():
|
||||
env = E2BEnvironment()
|
||||
with pytest.raises(RuntimeError):
|
||||
_ = env.working_dir
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_before_initialize_raises():
|
||||
env = E2BEnvironment()
|
||||
with pytest.raises(RuntimeError):
|
||||
await env.execute('echo hi')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_success(create_patch, sandbox):
|
||||
sandbox.commands.run.return_value = CommandResult(
|
||||
stdout='out', stderr='err', exit_code=0, error=None
|
||||
)
|
||||
env = E2BEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
result = await env.execute('echo out')
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout == 'out'
|
||||
assert result.stderr == 'err'
|
||||
assert result.timed_out is False
|
||||
sandbox.set_timeout.assert_awaited() # keepalive
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_nonzero_exit_is_normal_result(create_patch, sandbox):
|
||||
exc = CommandExitException(
|
||||
stdout='partial', stderr='boom', exit_code=2, error='failed'
|
||||
)
|
||||
sandbox.commands.run.side_effect = exc
|
||||
env = E2BEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
result = await env.execute('false')
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert result.stdout == 'partial'
|
||||
assert result.stderr == 'boom'
|
||||
assert result.timed_out is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_timeout(create_patch, sandbox):
|
||||
sandbox.commands.run.side_effect = TimeoutException('too slow')
|
||||
env = E2BEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
result = await env.execute('sleep 999')
|
||||
|
||||
assert result.timed_out is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_returns_bytes(create_patch, sandbox):
|
||||
sandbox.files.read.return_value = b'data'
|
||||
env = E2BEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
data = await env.read_file('notes.txt')
|
||||
|
||||
assert data == b'data'
|
||||
sandbox.files.read.assert_awaited_once_with(
|
||||
'/home/user/notes.txt', format='bytes'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_absolute_path_passthrough(create_patch, sandbox):
|
||||
sandbox.files.read.return_value = b'x'
|
||||
env = E2BEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
await env.read_file('/etc/hostname')
|
||||
|
||||
sandbox.files.read.assert_awaited_once_with('/etc/hostname', format='bytes')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_missing_raises(create_patch, sandbox):
|
||||
sandbox.files.read.side_effect = FileNotFoundException('nope')
|
||||
env = E2BEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await env.read_file('missing.txt')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_file_resolves_relative_path(create_patch, sandbox):
|
||||
env = E2BEnvironment()
|
||||
await env.initialize()
|
||||
|
||||
await env.write_file('sub/out.txt', 'hello')
|
||||
|
||||
sandbox.files.write.assert_awaited_once_with(
|
||||
'/home/user/sub/out.txt', 'hello'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keepalive_extends_timeout_when_running(create_patch, sandbox):
|
||||
sandbox.files.read.return_value = b'1'
|
||||
env = E2BEnvironment(timeout=200)
|
||||
await env.initialize()
|
||||
|
||||
await env.read_file('a.txt')
|
||||
|
||||
sandbox.set_timeout.assert_awaited_with(200)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_recreate_when_expired(sandbox):
|
||||
expired = _make_sandbox(running=False)
|
||||
fresh = _make_sandbox(running=True)
|
||||
fresh.files.read.return_value = b'fresh'
|
||||
|
||||
with mock.patch(
|
||||
'e2b.AsyncSandbox.create',
|
||||
new=mock.AsyncMock(side_effect=[expired, fresh]),
|
||||
) as create:
|
||||
env = E2BEnvironment()
|
||||
await env.initialize() # -> expired
|
||||
data = await env.read_file('a.txt') # detects dead, recreates -> fresh
|
||||
|
||||
assert data == b'fresh'
|
||||
assert create.await_count == 2
|
||||
assert env._sandbox is fresh
|
||||
expired.set_timeout.assert_not_awaited()
|
||||
@@ -0,0 +1,388 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.events.event import Event
|
||||
from google.adk.integrations.firestore.firestore_memory_service import FirestoreMemoryService
|
||||
from google.cloud.firestore_v1.base_query import FieldFilter
|
||||
from google.genai import types
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_firestore_client():
|
||||
client = mock.MagicMock()
|
||||
collection_ref = mock.MagicMock()
|
||||
client.collection.return_value = collection_ref
|
||||
|
||||
collection_ref.where.return_value = collection_ref
|
||||
|
||||
doc_snapshot = mock.MagicMock()
|
||||
doc_snapshot.to_dict.return_value = {}
|
||||
|
||||
collection_ref.get = mock.AsyncMock(return_value=[doc_snapshot])
|
||||
|
||||
return client
|
||||
|
||||
|
||||
def test_extract_keywords(mock_firestore_client):
|
||||
service = FirestoreMemoryService(client=mock_firestore_client)
|
||||
text = "The quick brown fox jumps over the lazy dog."
|
||||
keywords = service._extract_keywords(text)
|
||||
|
||||
assert "the" not in keywords
|
||||
assert "over" not in keywords
|
||||
assert "quick" in keywords
|
||||
assert "brown" in keywords
|
||||
assert "fox" in keywords
|
||||
assert "jumps" in keywords
|
||||
assert "lazy" in keywords
|
||||
assert "dog" in keywords
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memory_empty_query(mock_firestore_client):
|
||||
service = FirestoreMemoryService(client=mock_firestore_client)
|
||||
response = await service.search_memory(
|
||||
app_name="test_app", user_id="test_user", query=""
|
||||
)
|
||||
assert not response.memories
|
||||
mock_firestore_client.collection.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memory_with_results(mock_firestore_client):
|
||||
service = FirestoreMemoryService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
query = "quick fox"
|
||||
|
||||
doc_snapshot = mock_firestore_client.collection.return_value.where.return_value.where.return_value.where.return_value.get.return_value[
|
||||
0
|
||||
]
|
||||
|
||||
content = types.Content(parts=[types.Part.from_text(text="quick fox jumps")])
|
||||
|
||||
doc_snapshot.to_dict.return_value = {
|
||||
"appName": app_name,
|
||||
"userId": user_id,
|
||||
"author": "user",
|
||||
"content": content.model_dump(exclude_none=True, mode="json"),
|
||||
"timestamp": 1234567890.0,
|
||||
}
|
||||
|
||||
response = await service.search_memory(
|
||||
app_name=app_name, user_id=user_id, query=query
|
||||
)
|
||||
|
||||
assert response.memories
|
||||
assert len(response.memories) == 1
|
||||
assert response.memories[0].author == "user"
|
||||
|
||||
mock_firestore_client.collection.assert_called_with("memories")
|
||||
collection_ref = mock_firestore_client.collection.return_value
|
||||
|
||||
assert collection_ref.where.call_count == 6
|
||||
calls = collection_ref.where.call_args_list
|
||||
|
||||
app_name_calls = 0
|
||||
user_id_calls = 0
|
||||
keyword_calls = 0
|
||||
|
||||
for call in calls:
|
||||
kwargs = call.kwargs
|
||||
filt = kwargs.get("filter")
|
||||
if filt:
|
||||
if (
|
||||
filt.field_path == "appName"
|
||||
and filt.op_string == "=="
|
||||
and filt.value == app_name
|
||||
):
|
||||
app_name_calls += 1
|
||||
elif (
|
||||
filt.field_path == "userId"
|
||||
and filt.op_string == "=="
|
||||
and filt.value == user_id
|
||||
):
|
||||
user_id_calls += 1
|
||||
elif filt.field_path == "keywords" and filt.op_string == "array_contains":
|
||||
|
||||
if filt.value in ["quick", "fox"]:
|
||||
keyword_calls += 1
|
||||
|
||||
assert app_name_calls == 2
|
||||
assert user_id_calls == 2
|
||||
assert keyword_calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memory_deduplication(mock_firestore_client):
|
||||
service = FirestoreMemoryService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
query = "quick fox"
|
||||
|
||||
content = types.Content(parts=[types.Part.from_text(text="quick fox jumps")])
|
||||
|
||||
doc_snapshot1 = mock.MagicMock()
|
||||
doc_snapshot1.to_dict.return_value = {
|
||||
"appName": app_name,
|
||||
"userId": user_id,
|
||||
"author": "user",
|
||||
"content": content.model_dump(exclude_none=True, mode="json"),
|
||||
"timestamp": 1234567890.0,
|
||||
}
|
||||
|
||||
doc_snapshot2 = mock.MagicMock()
|
||||
doc_snapshot2.to_dict.return_value = {
|
||||
"appName": app_name,
|
||||
"userId": user_id,
|
||||
"author": "user",
|
||||
"content": content.model_dump(exclude_none=True, mode="json"),
|
||||
"timestamp": 1234567890.0,
|
||||
}
|
||||
|
||||
get_mock = mock.AsyncMock(side_effect=[[doc_snapshot1], [doc_snapshot2]])
|
||||
|
||||
mock_firestore_client.collection.return_value.where.return_value.where.return_value.where.return_value.get = (
|
||||
get_mock
|
||||
)
|
||||
|
||||
response = await service.search_memory(
|
||||
app_name=app_name, user_id=user_id, query=query
|
||||
)
|
||||
|
||||
assert response.memories
|
||||
assert len(response.memories) == 1
|
||||
assert response.memories[0].author == "user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memory_parsing_error(mock_firestore_client, caplog):
|
||||
service = FirestoreMemoryService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
query = "quick"
|
||||
|
||||
doc_snapshot = mock_firestore_client.collection.return_value.where.return_value.where.return_value.where.return_value.get.return_value[
|
||||
0
|
||||
]
|
||||
doc_snapshot.to_dict.return_value = {"content": "invalid_data"}
|
||||
|
||||
response = await service.search_memory(
|
||||
app_name=app_name, user_id=user_id, query=query
|
||||
)
|
||||
|
||||
assert not response.memories
|
||||
assert "Failed to parse memory entry" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memory_only_stop_words(mock_firestore_client):
|
||||
service = FirestoreMemoryService(client=mock_firestore_client)
|
||||
response = await service.search_memory(
|
||||
app_name="test_app", user_id="test_user", query="the and or"
|
||||
)
|
||||
assert not response.memories
|
||||
mock_firestore_client.collection.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memory_partial_failures(mock_firestore_client, caplog):
|
||||
service = FirestoreMemoryService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
query = "fox quick"
|
||||
|
||||
coll_ref = (
|
||||
mock_firestore_client.collection.return_value.where.return_value.where.return_value.where.return_value
|
||||
)
|
||||
|
||||
doc_snapshot = mock.MagicMock()
|
||||
doc_snapshot.to_dict.return_value = {
|
||||
"content": {"parts": [{"text": "quick response"}]},
|
||||
"author": "user",
|
||||
"timestamp": 1234567890.0,
|
||||
}
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_get():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise ValueError("Mock generic network failure standalone")
|
||||
return [doc_snapshot]
|
||||
|
||||
coll_ref.get = mock.AsyncMock(side_effect=mock_get)
|
||||
|
||||
response = await service.search_memory(
|
||||
app_name=app_name, user_id=user_id, query=query
|
||||
)
|
||||
|
||||
assert len(response.memories) == 1
|
||||
assert response.memories[0].author == "user"
|
||||
assert "Memory keyword search partial failure" in caplog.text
|
||||
|
||||
|
||||
def test_init_default_client():
|
||||
with mock.patch("google.cloud.firestore.AsyncClient") as mock_client_class:
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_client_class.return_value = mock_instance
|
||||
|
||||
service = FirestoreMemoryService()
|
||||
|
||||
mock_client_class.assert_called_once()
|
||||
assert service.client == mock_instance
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_session_to_memory(mock_firestore_client):
|
||||
service = FirestoreMemoryService(client=mock_firestore_client)
|
||||
|
||||
from google.adk.sessions.session import Session
|
||||
|
||||
session = Session(id="test_session", app_name="test_app", user_id="test_user")
|
||||
|
||||
content = types.Content(parts=[types.Part.from_text(text="quick brown fox")])
|
||||
event = Event(
|
||||
invocation_id="test_inv",
|
||||
author="user",
|
||||
content=content,
|
||||
timestamp=1234567890.0,
|
||||
)
|
||||
session.events.append(event)
|
||||
|
||||
batch = mock.MagicMock()
|
||||
mock_firestore_client.batch.return_value = batch
|
||||
batch.commit = mock.AsyncMock()
|
||||
|
||||
doc_ref = mock.MagicMock()
|
||||
mock_firestore_client.collection.return_value.document.return_value = doc_ref
|
||||
|
||||
await service.add_session_to_memory(session)
|
||||
|
||||
mock_firestore_client.batch.assert_called_once()
|
||||
mock_firestore_client.collection.assert_called_with("memories")
|
||||
batch.set.assert_called_once()
|
||||
batch.commit.assert_called_once()
|
||||
|
||||
args, kwargs = batch.set.call_args
|
||||
assert args[0] == doc_ref
|
||||
data = args[1]
|
||||
assert data["appName"] == "test_app"
|
||||
assert data["userId"] == "test_user"
|
||||
assert "quick" in data["keywords"]
|
||||
assert data["author"] == "user"
|
||||
assert data["timestamp"] == 1234567890.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_session_to_memory_no_events(mock_firestore_client):
|
||||
service = FirestoreMemoryService(client=mock_firestore_client)
|
||||
|
||||
from google.adk.sessions.session import Session
|
||||
|
||||
session = Session(id="test_session", app_name="test_app", user_id="test_user")
|
||||
|
||||
batch = mock.MagicMock()
|
||||
mock_firestore_client.batch.return_value = batch
|
||||
|
||||
await service.add_session_to_memory(session)
|
||||
|
||||
mock_firestore_client.batch.assert_called_once()
|
||||
batch.set.assert_not_called()
|
||||
batch.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_session_to_memory_no_keywords(mock_firestore_client):
|
||||
service = FirestoreMemoryService(client=mock_firestore_client)
|
||||
|
||||
from google.adk.sessions.session import Session
|
||||
|
||||
session = Session(id="test_session", app_name="test_app", user_id="test_user")
|
||||
|
||||
content = types.Content(parts=[types.Part.from_text(text="the and or")])
|
||||
event = Event(invocation_id="test_inv", author="user", content=content)
|
||||
session.events.append(event)
|
||||
|
||||
batch = mock.MagicMock()
|
||||
mock_firestore_client.batch.return_value = batch
|
||||
|
||||
await service.add_session_to_memory(session)
|
||||
|
||||
mock_firestore_client.batch.assert_called_once()
|
||||
batch.set.assert_not_called()
|
||||
batch.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_session_to_memory_commit_error(mock_firestore_client):
|
||||
service = FirestoreMemoryService(client=mock_firestore_client)
|
||||
|
||||
from google.adk.sessions.session import Session
|
||||
|
||||
session = Session(id="test_session", app_name="test_app", user_id="test_user")
|
||||
|
||||
content = types.Content(parts=[types.Part.from_text(text="quick brown fox")])
|
||||
event = Event(invocation_id="test_inv", author="user", content=content)
|
||||
session.events.append(event)
|
||||
|
||||
batch = mock.MagicMock()
|
||||
mock_firestore_client.batch.return_value = batch
|
||||
batch.commit = mock.AsyncMock(
|
||||
side_effect=Exception("Firestore commit failed")
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="Firestore commit failed"):
|
||||
await service.add_session_to_memory(session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_session_to_memory_exceeds_batch_limit(mock_firestore_client):
|
||||
service = FirestoreMemoryService(client=mock_firestore_client)
|
||||
|
||||
from google.adk.sessions.session import Session
|
||||
|
||||
session = Session(id="test_session", app_name="test_app", user_id="test_user")
|
||||
|
||||
for i in range(501):
|
||||
content = types.Content(
|
||||
parts=[types.Part.from_text(text=f"event keyword {i}")]
|
||||
)
|
||||
event = Event(
|
||||
invocation_id=f"test_inv_{i}",
|
||||
author="user",
|
||||
content=content,
|
||||
timestamp=1234567890.0 + i,
|
||||
)
|
||||
session.events.append(event)
|
||||
|
||||
batch1 = mock.MagicMock()
|
||||
batch2 = mock.MagicMock()
|
||||
batch1.commit = mock.AsyncMock()
|
||||
batch2.commit = mock.AsyncMock()
|
||||
mock_firestore_client.batch.side_effect = [batch1, batch2]
|
||||
|
||||
await service.add_session_to_memory(session)
|
||||
|
||||
assert mock_firestore_client.batch.call_count == 2
|
||||
assert batch1.set.call_count == 500
|
||||
batch1.commit.assert_called_once()
|
||||
assert batch2.set.call_count == 1
|
||||
batch2.commit.assert_called_once()
|
||||
@@ -0,0 +1,830 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.errors.already_exists_error import AlreadyExistsError
|
||||
from google.adk.events.event import Event
|
||||
from google.adk.events.event import EventActions
|
||||
from google.adk.integrations.firestore.firestore_session_service import FirestoreSessionService
|
||||
from google.adk.sessions.base_session_service import GetSessionConfig
|
||||
from google.adk.sessions.session import Session
|
||||
from google.cloud import firestore
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_firestore_client():
|
||||
client = mock.MagicMock()
|
||||
collection_ref = mock.MagicMock()
|
||||
doc_ref = mock.MagicMock()
|
||||
subcollection_ref = mock.MagicMock()
|
||||
subdoc_ref = mock.MagicMock()
|
||||
sessions_coll_ref = mock.MagicMock()
|
||||
sessions_doc_ref = mock.MagicMock()
|
||||
|
||||
client.collection.return_value = collection_ref
|
||||
collection_ref.document.return_value = doc_ref
|
||||
doc_ref.collection.return_value = subcollection_ref
|
||||
subcollection_ref.document.return_value = subdoc_ref
|
||||
subdoc_ref.collection.return_value = sessions_coll_ref
|
||||
sessions_coll_ref.document.return_value = sessions_doc_ref
|
||||
|
||||
doc_snapshot = mock.MagicMock()
|
||||
doc_snapshot.exists = False
|
||||
doc_snapshot.to_dict.return_value = {}
|
||||
|
||||
subdoc_ref.get = mock.AsyncMock(return_value=doc_snapshot)
|
||||
sessions_doc_ref.get = mock.AsyncMock(return_value=doc_snapshot)
|
||||
doc_ref.get = mock.AsyncMock(return_value=doc_snapshot)
|
||||
|
||||
sessions_doc_ref.set = mock.AsyncMock()
|
||||
sessions_doc_ref.delete = mock.AsyncMock()
|
||||
|
||||
events_collection_ref = mock.MagicMock()
|
||||
sessions_doc_ref.collection.return_value = events_collection_ref
|
||||
events_collection_ref.order_by.return_value = events_collection_ref
|
||||
events_collection_ref.where.return_value = events_collection_ref
|
||||
events_collection_ref.limit_to_last.return_value = events_collection_ref
|
||||
events_collection_ref.get = mock.AsyncMock(return_value=[])
|
||||
|
||||
sessions_coll_ref.get = mock.AsyncMock(return_value=[])
|
||||
sessions_coll_ref.where.return_value = sessions_coll_ref
|
||||
|
||||
client.collection_group.return_value = collection_ref
|
||||
|
||||
batch = mock.MagicMock()
|
||||
client.batch.return_value = batch
|
||||
batch.commit = mock.AsyncMock()
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session(mock_firestore_client):
|
||||
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
|
||||
with mock.patch("google.cloud.firestore.async_transactional", lambda x: x):
|
||||
session = await service.create_session(app_name=app_name, user_id=user_id)
|
||||
|
||||
assert session.app_name == app_name
|
||||
assert session.user_id == user_id
|
||||
assert session.id
|
||||
assert session._storage_update_marker == "0"
|
||||
|
||||
mock_firestore_client.collection.assert_any_call("adk-session")
|
||||
mock_firestore_client.collection.assert_any_call("app_states")
|
||||
mock_firestore_client.collection.assert_any_call("user_states")
|
||||
|
||||
root_coll = mock_firestore_client.collection.return_value
|
||||
app_ref = root_coll.document.return_value
|
||||
users_coll = app_ref.collection.return_value
|
||||
user_ref = users_coll.document.return_value
|
||||
sessions_ref = user_ref.collection.return_value
|
||||
session_doc_ref = sessions_ref.document.return_value
|
||||
|
||||
transaction = mock_firestore_client.transaction.return_value
|
||||
transaction.set.assert_called_once()
|
||||
args, kwargs = transaction.set.call_args
|
||||
assert args[0] == session_doc_ref
|
||||
assert args[1]["id"] == session.id
|
||||
assert args[1]["appName"] == app_name
|
||||
assert args[1]["userId"] == user_id
|
||||
assert json.loads(args[1]["state"]) == {}
|
||||
assert args[1]["createTime"] == firestore.SERVER_TIMESTAMP
|
||||
assert args[1]["updateTime"] == firestore.SERVER_TIMESTAMP
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_not_found(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
session_id = "test_session"
|
||||
|
||||
session = await service.get_session(
|
||||
app_name=app_name, user_id=user_id, session_id=session_id
|
||||
)
|
||||
|
||||
assert session is None
|
||||
|
||||
mock_firestore_client.collection.assert_called_with("adk-session")
|
||||
root_coll = mock_firestore_client.collection.return_value
|
||||
root_coll.document.assert_called_with(app_name)
|
||||
app_ref = root_coll.document.return_value
|
||||
app_ref.collection.assert_called_with("users")
|
||||
users_coll = app_ref.collection.return_value
|
||||
users_coll.document.assert_called_with(user_id)
|
||||
user_ref = users_coll.document.return_value
|
||||
user_ref.collection.assert_called_with("sessions")
|
||||
sessions_ref = user_ref.collection.return_value
|
||||
sessions_ref.document.assert_called_with(session_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_found(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
session_id = "test_session"
|
||||
|
||||
root_coll = mock_firestore_client.collection.return_value
|
||||
app_ref = root_coll.document.return_value
|
||||
users_coll = app_ref.collection.return_value
|
||||
user_ref = users_coll.document.return_value
|
||||
sessions_ref = user_ref.collection.return_value
|
||||
sessions_doc_ref = sessions_ref.document.return_value
|
||||
|
||||
session_snap = mock.MagicMock()
|
||||
session_snap.exists = True
|
||||
session_snap.to_dict.return_value = {
|
||||
"id": session_id,
|
||||
"appName": app_name,
|
||||
"userId": user_id,
|
||||
"state": {"key": "value"},
|
||||
"updateTime": 1234567890.0,
|
||||
}
|
||||
sessions_doc_ref.get.return_value = session_snap
|
||||
|
||||
# Decouple app and user documents so they do not duplicate values
|
||||
app_state_coll = mock_firestore_client.collection.return_value
|
||||
app_doc_ref = app_state_coll.document.return_value
|
||||
app_snap = mock.MagicMock()
|
||||
app_snap.exists = False
|
||||
app_snap.to_dict.return_value = {}
|
||||
app_doc_ref.get.return_value = app_snap
|
||||
|
||||
user_state_coll = mock_firestore_client.collection.return_value
|
||||
user_doc_ref = user_state_coll.document.return_value
|
||||
user_snap = mock.MagicMock()
|
||||
user_snap.exists = False
|
||||
user_snap.to_dict.return_value = {}
|
||||
user_doc_ref.get.return_value = user_snap
|
||||
|
||||
events_collection_ref = (
|
||||
mock_firestore_client.collection.return_value.document.return_value.collection.return_value.document.return_value.collection.return_value.document.return_value.collection.return_value
|
||||
)
|
||||
event_doc = mock.MagicMock()
|
||||
event_doc.to_dict.return_value = {
|
||||
"event_data": {"invocation_id": "test_inv", "author": "user"}
|
||||
}
|
||||
events_collection_ref.get = mock.AsyncMock(return_value=[event_doc])
|
||||
|
||||
session = await service.get_session(
|
||||
app_name=app_name, user_id=user_id, session_id=session_id
|
||||
)
|
||||
|
||||
assert session is not None
|
||||
assert session.id == session_id
|
||||
assert session.state == {"key": "value"}
|
||||
assert len(session.events) == 1
|
||||
assert session.events[0].invocation_id == "test_inv"
|
||||
assert session._storage_update_marker == "0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_session(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
session_id = "test_session"
|
||||
|
||||
events_ref = (
|
||||
mock_firestore_client.collection.return_value.document.return_value.collection.return_value.document.return_value.collection.return_value.document.return_value.collection.return_value
|
||||
)
|
||||
event_doc = mock.AsyncMock()
|
||||
|
||||
async def to_async_iter(iterable):
|
||||
for item in iterable:
|
||||
yield item
|
||||
|
||||
events_ref.stream.return_value = to_async_iter([event_doc])
|
||||
|
||||
await service.delete_session(
|
||||
app_name=app_name, user_id=user_id, session_id=session_id
|
||||
)
|
||||
|
||||
events_ref.stream.assert_called_once()
|
||||
mock_firestore_client.batch.assert_called_once()
|
||||
batch = mock_firestore_client.batch.return_value
|
||||
batch.delete.assert_called_once_with(event_doc.reference)
|
||||
batch.commit.assert_called_once()
|
||||
|
||||
session_doc_ref = (
|
||||
mock_firestore_client.collection.return_value.document.return_value.collection.return_value.document.return_value.collection.return_value.document.return_value
|
||||
)
|
||||
session_doc_ref.delete.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_event(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
session = Session(id="test_session", app_name=app_name, user_id=user_id)
|
||||
event = Event(invocation_id="test_inv", author="user")
|
||||
|
||||
session_doc_snapshot = mock.MagicMock()
|
||||
session_doc_snapshot.exists = True
|
||||
session_doc_snapshot.to_dict.return_value = {"revision": 0}
|
||||
|
||||
root_coll = mock_firestore_client.collection.return_value
|
||||
app_ref = root_coll.document.return_value
|
||||
users_coll = app_ref.collection.return_value
|
||||
user_ref = users_coll.document.return_value
|
||||
sessions_ref = user_ref.collection.return_value
|
||||
session_doc_ref = sessions_ref.document.return_value
|
||||
session_doc_ref.get = mock.AsyncMock(return_value=session_doc_snapshot)
|
||||
|
||||
with mock.patch("google.cloud.firestore.async_transactional", lambda x: x):
|
||||
await service.append_event(session, event)
|
||||
|
||||
transaction = mock_firestore_client.transaction.return_value
|
||||
transaction.set.assert_called() # Invoked for events appends
|
||||
transaction.update.assert_called_once() # Invoked for session revisions
|
||||
|
||||
args, kwargs = transaction.update.call_args
|
||||
assert args[1]["revision"] == 1
|
||||
assert args[1]["updateTime"] == firestore.SERVER_TIMESTAMP
|
||||
assert session.last_update_time == event.timestamp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_event_with_state_delta(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
session = Session(id="test_session", app_name=app_name, user_id=user_id)
|
||||
|
||||
event = mock.MagicMock()
|
||||
event.partial = False
|
||||
event.id = "test_event_id"
|
||||
event.actions.state_delta = {
|
||||
"_app_my_key": "app_val",
|
||||
"_user_my_key": "user_val",
|
||||
"session_key": "session_val",
|
||||
}
|
||||
event.model_dump.return_value = {"id": "test_event_id", "author": "user"}
|
||||
|
||||
service._update_app_state_transactional = mock.AsyncMock()
|
||||
service._update_user_state_transactional = mock.AsyncMock()
|
||||
|
||||
session_doc_snapshot = mock.MagicMock()
|
||||
session_doc_snapshot.exists = True
|
||||
session_doc_snapshot.to_dict.return_value = {"revision": 0}
|
||||
|
||||
root_coll = mock_firestore_client.collection.return_value
|
||||
app_ref = root_coll.document.return_value
|
||||
users_coll = app_ref.collection.return_value
|
||||
user_ref = users_coll.document.return_value
|
||||
sessions_ref = user_ref.collection.return_value
|
||||
session_doc_ref = sessions_ref.document.return_value
|
||||
session_doc_ref.get = mock.AsyncMock(return_value=session_doc_snapshot)
|
||||
|
||||
with mock.patch("google.cloud.firestore.async_transactional", lambda x: x):
|
||||
await service.append_event(session, event)
|
||||
|
||||
transaction = mock_firestore_client.transaction.return_value
|
||||
transaction.set.assert_called()
|
||||
|
||||
assert session.state["session_key"] == "session_val"
|
||||
|
||||
transaction.update.assert_called_once()
|
||||
args, kwargs = transaction.update.call_args
|
||||
assert json.loads(args[1]["state"]) == session.state
|
||||
assert args[1]["updateTime"] == firestore.SERVER_TIMESTAMP
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_event_with_temp_state(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
session = Session(id="test_session", app_name=app_name, user_id=user_id)
|
||||
|
||||
event = Event(
|
||||
invocation_id="test_inv",
|
||||
author="user",
|
||||
actions=EventActions(
|
||||
state_delta={"temp:k1": "v1", "session_key": "session_val"}
|
||||
),
|
||||
)
|
||||
|
||||
session_doc_snapshot = mock.MagicMock()
|
||||
session_doc_snapshot.exists = True
|
||||
session_doc_snapshot.to_dict.return_value = {"revision": 0}
|
||||
|
||||
root_coll = mock_firestore_client.collection.return_value
|
||||
app_ref = root_coll.document.return_value
|
||||
users_coll = app_ref.collection.return_value
|
||||
user_ref = users_coll.document.return_value
|
||||
sessions_ref = user_ref.collection.return_value
|
||||
session_doc_ref = sessions_ref.document.return_value
|
||||
session_doc_ref.get = mock.AsyncMock(return_value=session_doc_snapshot)
|
||||
|
||||
with mock.patch("google.cloud.firestore.async_transactional", lambda x: x):
|
||||
await service.append_event(session, event)
|
||||
|
||||
# 1. Verify it was applied in-memory
|
||||
assert session.state["temp:k1"] == "v1"
|
||||
assert session.state["session_key"] == "session_val"
|
||||
|
||||
# 2. Verify it was trimmed before Firestore save
|
||||
transaction = mock_firestore_client.transaction.return_value
|
||||
transaction.set.assert_called()
|
||||
|
||||
# Filter calls for the one that actually sets the event data
|
||||
event_set_calls = [
|
||||
call
|
||||
for call in transaction.set.call_args_list
|
||||
if len(call[0]) > 1
|
||||
and isinstance(call[0][1], dict)
|
||||
and "event_data" in call[0][1]
|
||||
]
|
||||
assert len(event_set_calls) == 1
|
||||
event_data = event_set_calls[0][0][1]["event_data"]
|
||||
|
||||
# Temporary keys should be deleted from delta before snapshot
|
||||
assert "temp:k1" not in event_data["actions"]["state_delta"]
|
||||
assert event_data["actions"]["state_delta"]["session_key"] == "session_val"
|
||||
|
||||
# 3. Verify temp keys are NOT written to session state in Firestore
|
||||
transaction.update.assert_called_once()
|
||||
update_args, _ = transaction.update.call_args
|
||||
persisted_state = update_args[1]["state"]
|
||||
assert (
|
||||
"temp:k1" not in persisted_state
|
||||
), "temp: keys must not be persisted to Firestore session state"
|
||||
assert "session_key" in persisted_state
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_with_user_id(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
|
||||
session_doc = mock.MagicMock()
|
||||
session_doc.to_dict.return_value = {
|
||||
"id": "session1",
|
||||
"appName": app_name,
|
||||
"userId": user_id,
|
||||
"state": {"session_key": "session_val"},
|
||||
"updateTime": 1234567890.0,
|
||||
}
|
||||
|
||||
app_state_coll = mock.MagicMock()
|
||||
user_state_coll = mock.MagicMock()
|
||||
sessions_coll = mock.MagicMock()
|
||||
|
||||
def collection_side_effect(name):
|
||||
if name == service.app_state_collection:
|
||||
return app_state_coll
|
||||
elif name == service.user_state_collection:
|
||||
return user_state_coll
|
||||
elif name == service.root_collection:
|
||||
return sessions_coll
|
||||
return mock.MagicMock()
|
||||
|
||||
mock_firestore_client.collection.side_effect = collection_side_effect
|
||||
|
||||
app_doc = mock.MagicMock()
|
||||
app_doc.exists = True
|
||||
app_doc.to_dict.return_value = {"app_key": "app_val"}
|
||||
app_doc_ref = mock.MagicMock()
|
||||
app_state_coll.document.return_value = app_doc_ref
|
||||
app_doc_ref.get = mock.AsyncMock(return_value=app_doc)
|
||||
|
||||
user_doc = mock.MagicMock()
|
||||
user_doc.exists = True
|
||||
user_doc.to_dict.return_value = {"user_key": "user_val"}
|
||||
user_app_doc = mock.MagicMock()
|
||||
user_state_coll.document.return_value = user_app_doc
|
||||
users_coll = mock.MagicMock()
|
||||
user_app_doc.collection.return_value = users_coll
|
||||
user_doc_ref = mock.MagicMock()
|
||||
users_coll.document.return_value = user_doc_ref
|
||||
user_doc_ref.get = mock.AsyncMock(return_value=user_doc)
|
||||
|
||||
app_doc_in_root = mock.MagicMock()
|
||||
sessions_coll.document.return_value = app_doc_in_root
|
||||
users_coll = mock.MagicMock()
|
||||
app_doc_in_root.collection.return_value = users_coll
|
||||
user_doc_in_users = mock.MagicMock()
|
||||
users_coll.document.return_value = user_doc_in_users
|
||||
sessions_subcoll = mock.MagicMock()
|
||||
user_doc_in_users.collection.return_value = sessions_subcoll
|
||||
sessions_query = mock.MagicMock()
|
||||
sessions_subcoll.where.return_value = sessions_query
|
||||
sessions_query.get = mock.AsyncMock(return_value=[session_doc])
|
||||
|
||||
response = await service.list_sessions(app_name=app_name, user_id=user_id)
|
||||
|
||||
assert len(response.sessions) == 1
|
||||
session = response.sessions[0]
|
||||
assert session.id == "session1"
|
||||
assert session.state["session_key"] == "session_val"
|
||||
assert session.state["app:app_key"] == "app_val"
|
||||
assert session.state["user:user_key"] == "user_val"
|
||||
assert session.last_update_time == 1234567890.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_preserves_datetime_update_time(
|
||||
mock_firestore_client,
|
||||
):
|
||||
"""list_sessions converts datetime updateTime to last_update_time."""
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
update_time = datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc)
|
||||
|
||||
session_doc = mock.MagicMock()
|
||||
session_doc.to_dict.return_value = {
|
||||
"id": "session1",
|
||||
"appName": app_name,
|
||||
"userId": user_id,
|
||||
"state": {},
|
||||
"updateTime": update_time,
|
||||
}
|
||||
|
||||
app_state_coll = mock.MagicMock()
|
||||
user_state_coll = mock.MagicMock()
|
||||
sessions_coll = mock.MagicMock()
|
||||
|
||||
def collection_side_effect(name):
|
||||
if name == service.app_state_collection:
|
||||
return app_state_coll
|
||||
elif name == service.user_state_collection:
|
||||
return user_state_coll
|
||||
elif name == service.root_collection:
|
||||
return sessions_coll
|
||||
return mock.MagicMock()
|
||||
|
||||
mock_firestore_client.collection.side_effect = collection_side_effect
|
||||
|
||||
app_doc = mock.MagicMock()
|
||||
app_doc.exists = False
|
||||
app_doc.to_dict.return_value = {}
|
||||
app_doc_ref = mock.MagicMock()
|
||||
app_state_coll.document.return_value = app_doc_ref
|
||||
app_doc_ref.get = mock.AsyncMock(return_value=app_doc)
|
||||
|
||||
user_doc = mock.MagicMock()
|
||||
user_doc.exists = False
|
||||
user_doc.to_dict.return_value = {}
|
||||
user_app_doc = mock.MagicMock()
|
||||
user_state_coll.document.return_value = user_app_doc
|
||||
users_coll = mock.MagicMock()
|
||||
user_app_doc.collection.return_value = users_coll
|
||||
user_doc_ref = mock.MagicMock()
|
||||
users_coll.document.return_value = user_doc_ref
|
||||
user_doc_ref.get = mock.AsyncMock(return_value=user_doc)
|
||||
|
||||
app_doc_in_root = mock.MagicMock()
|
||||
sessions_coll.document.return_value = app_doc_in_root
|
||||
users_coll = mock.MagicMock()
|
||||
app_doc_in_root.collection.return_value = users_coll
|
||||
user_doc_in_users = mock.MagicMock()
|
||||
users_coll.document.return_value = user_doc_in_users
|
||||
sessions_subcoll = mock.MagicMock()
|
||||
user_doc_in_users.collection.return_value = sessions_subcoll
|
||||
sessions_query = mock.MagicMock()
|
||||
sessions_subcoll.where.return_value = sessions_query
|
||||
sessions_query.get = mock.AsyncMock(return_value=[session_doc])
|
||||
|
||||
response = await service.list_sessions(app_name=app_name, user_id=user_id)
|
||||
|
||||
assert len(response.sessions) == 1
|
||||
assert response.sessions[0].last_update_time == update_time.timestamp()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_without_user_id(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
|
||||
session_doc = mock.MagicMock()
|
||||
session_doc.to_dict.return_value = {
|
||||
"id": "session1",
|
||||
"appName": app_name,
|
||||
"userId": "user1",
|
||||
"state": {"session_key": "session_val"},
|
||||
"updateTime": 1234567890.0,
|
||||
}
|
||||
|
||||
mock_firestore_client.collection_group.return_value.where.return_value.get = (
|
||||
mock.AsyncMock(return_value=[session_doc])
|
||||
)
|
||||
|
||||
app_state_coll = mock.MagicMock()
|
||||
user_state_coll = mock.MagicMock()
|
||||
|
||||
def collection_side_effect(name):
|
||||
if name == service.app_state_collection:
|
||||
return app_state_coll
|
||||
elif name == service.user_state_collection:
|
||||
return user_state_coll
|
||||
return mock.MagicMock()
|
||||
|
||||
mock_firestore_client.collection.side_effect = collection_side_effect
|
||||
|
||||
app_doc = mock.MagicMock()
|
||||
app_doc.exists = True
|
||||
app_doc.to_dict.return_value = {"app_key": "app_val"}
|
||||
app_doc_ref = mock.MagicMock()
|
||||
app_state_coll.document.return_value = app_doc_ref
|
||||
app_doc_ref.get = mock.AsyncMock(return_value=app_doc)
|
||||
|
||||
user_doc = mock.MagicMock()
|
||||
user_doc.id = "user1"
|
||||
user_doc.exists = True
|
||||
user_doc.to_dict.return_value = {"user_key": "user_val"}
|
||||
user_app_doc = mock.MagicMock()
|
||||
user_state_coll.document.return_value = user_app_doc
|
||||
users_coll = mock.MagicMock()
|
||||
user_app_doc.collection.return_value = users_coll
|
||||
user_doc_ref = mock.MagicMock()
|
||||
users_coll.document.return_value = user_doc_ref
|
||||
|
||||
async def mock_get_all(refs):
|
||||
yield user_doc
|
||||
|
||||
mock_firestore_client.get_all = mock_get_all
|
||||
|
||||
response = await service.list_sessions(app_name=app_name)
|
||||
|
||||
assert len(response.sessions) == 1
|
||||
session = response.sessions[0]
|
||||
assert session.id == "session1"
|
||||
assert session.state["app:app_key"] == "app_val"
|
||||
assert session.state["user:user_key"] == "user_val"
|
||||
assert session.last_update_time == 1234567890.0
|
||||
|
||||
mock_firestore_client.collection_group.assert_called_once_with("sessions")
|
||||
mock_firestore_client.collection_group.return_value.where.assert_called_once_with(
|
||||
"appName", "==", app_name
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_filters_other_apps(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
|
||||
session_doc = mock.MagicMock()
|
||||
session_doc.to_dict.return_value = {
|
||||
"id": "session1",
|
||||
"appName": app_name,
|
||||
"userId": "user1",
|
||||
"state": {"session_key": "session_val"},
|
||||
}
|
||||
|
||||
mock_firestore_client.collection_group.return_value.where.return_value.get = (
|
||||
mock.AsyncMock(return_value=[session_doc])
|
||||
)
|
||||
|
||||
app_state_coll = mock.MagicMock()
|
||||
user_state_coll = mock.MagicMock()
|
||||
|
||||
def collection_side_effect(name):
|
||||
if name == service.app_state_collection:
|
||||
return app_state_coll
|
||||
elif name == service.user_state_collection:
|
||||
return user_state_coll
|
||||
return mock.MagicMock()
|
||||
|
||||
mock_firestore_client.collection.side_effect = collection_side_effect
|
||||
|
||||
app_doc = mock.MagicMock()
|
||||
app_doc.exists = True
|
||||
app_doc.to_dict.return_value = {"app_key": "app_val"}
|
||||
app_doc_ref = mock.MagicMock()
|
||||
app_state_coll.document.return_value = app_doc_ref
|
||||
app_doc_ref.get = mock.AsyncMock(return_value=app_doc)
|
||||
|
||||
user_doc = mock.MagicMock()
|
||||
user_doc.id = "user1"
|
||||
user_doc.exists = True
|
||||
user_doc.to_dict.return_value = {"user_key": "user_val"}
|
||||
user_app_doc = mock.MagicMock()
|
||||
user_state_coll.document.return_value = user_app_doc
|
||||
users_coll = mock.MagicMock()
|
||||
user_app_doc.collection.return_value = users_coll
|
||||
user_doc_ref = mock.MagicMock()
|
||||
users_coll.document.return_value = user_doc_ref
|
||||
|
||||
async def mock_get_all(refs):
|
||||
yield user_doc
|
||||
|
||||
mock_firestore_client.get_all = mock_get_all
|
||||
|
||||
response = await service.list_sessions(app_name=app_name)
|
||||
|
||||
assert len(response.sessions) == 1
|
||||
assert response.sessions[0].id == "session1"
|
||||
assert response.sessions[0].app_name == app_name
|
||||
|
||||
mock_firestore_client.collection_group.assert_called_once_with("sessions")
|
||||
mock_firestore_client.collection_group.return_value.where.assert_called_once_with(
|
||||
"appName", "==", app_name
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_already_exists(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
|
||||
doc_snapshot = (
|
||||
mock_firestore_client.collection.return_value.document.return_value.collection.return_value.document.return_value.get.return_value
|
||||
)
|
||||
doc_snapshot.exists = True
|
||||
|
||||
with mock.patch("google.cloud.firestore.async_transactional", lambda x: x):
|
||||
with pytest.raises(AlreadyExistsError):
|
||||
await service.create_session(
|
||||
app_name=app_name, user_id=user_id, session_id="existing_id"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_with_config(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
session_id = "test_session"
|
||||
|
||||
doc_snapshot = (
|
||||
mock_firestore_client.collection.return_value.document.return_value.collection.return_value.document.return_value.get.return_value
|
||||
)
|
||||
doc_snapshot.exists = True
|
||||
doc_snapshot.to_dict.return_value = {
|
||||
"id": session_id,
|
||||
"appName": app_name,
|
||||
"userId": user_id,
|
||||
}
|
||||
|
||||
events_collection_ref = (
|
||||
mock_firestore_client.collection.return_value.document.return_value.collection.return_value.document.return_value.collection.return_value.document.return_value.collection.return_value
|
||||
)
|
||||
|
||||
config = GetSessionConfig(after_timestamp=1234567890.0, num_recent_events=5)
|
||||
|
||||
await service.get_session(
|
||||
app_name=app_name, user_id=user_id, session_id=session_id, config=config
|
||||
)
|
||||
|
||||
events_collection_ref.where.assert_called_once()
|
||||
events_collection_ref.limit_to_last.assert_called_once_with(5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_session_batching(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
session_id = "test_session"
|
||||
|
||||
events_ref = (
|
||||
mock_firestore_client.collection.return_value.document.return_value.collection.return_value.document.return_value.collection.return_value.document.return_value.collection.return_value
|
||||
)
|
||||
|
||||
dummy_docs = [mock.MagicMock() for _ in range(501)]
|
||||
|
||||
async def to_async_iter(iterable):
|
||||
for item in iterable:
|
||||
yield item
|
||||
|
||||
events_ref.stream.return_value = to_async_iter(dummy_docs)
|
||||
|
||||
batch = mock_firestore_client.batch.return_value
|
||||
|
||||
await service.delete_session(
|
||||
app_name=app_name, user_id=user_id, session_id=session_id
|
||||
)
|
||||
|
||||
assert batch.commit.call_count == 2
|
||||
assert batch.delete.call_count == 501
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_event_partial(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
|
||||
session = Session(id="test_session", app_name="test_app", user_id="test_user")
|
||||
|
||||
event = Event(invocation_id="test_inv", author="user", partial=True)
|
||||
|
||||
result = await service.append_event(session, event)
|
||||
|
||||
assert result == event
|
||||
mock_firestore_client.batch.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_empty_data(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
session_id = "test_session"
|
||||
|
||||
doc_snapshot = (
|
||||
mock_firestore_client.collection.return_value.document.return_value.collection.return_value.document.return_value.get.return_value
|
||||
)
|
||||
doc_snapshot.exists = True
|
||||
doc_snapshot.to_dict.return_value = {}
|
||||
|
||||
session = await service.get_session(
|
||||
app_name=app_name, user_id=user_id, session_id=session_id
|
||||
)
|
||||
|
||||
assert session is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_missing_states(mock_firestore_client):
|
||||
service = FirestoreSessionService(client=mock_firestore_client)
|
||||
app_name = "test_app"
|
||||
user_id = "test_user"
|
||||
|
||||
session_doc = mock.MagicMock()
|
||||
session_doc.to_dict.return_value = {
|
||||
"id": "session1",
|
||||
"appName": app_name,
|
||||
"userId": user_id,
|
||||
"state": {"session_key": "session_val"},
|
||||
}
|
||||
|
||||
app_state_coll = mock.MagicMock()
|
||||
user_state_coll = mock.MagicMock()
|
||||
sessions_coll = mock.MagicMock()
|
||||
|
||||
def collection_side_effect(name):
|
||||
if name == service.app_state_collection:
|
||||
return app_state_coll
|
||||
elif name == service.user_state_collection:
|
||||
return user_state_coll
|
||||
elif name == service.root_collection:
|
||||
return sessions_coll
|
||||
return mock.MagicMock()
|
||||
|
||||
mock_firestore_client.collection.side_effect = collection_side_effect
|
||||
|
||||
app_doc = mock.MagicMock()
|
||||
app_doc.exists = False
|
||||
app_doc_ref = mock.MagicMock()
|
||||
app_state_coll.document.return_value = app_doc_ref
|
||||
app_doc_ref.get = mock.AsyncMock(return_value=app_doc)
|
||||
|
||||
user_doc = mock.MagicMock()
|
||||
user_doc.exists = False
|
||||
user_app_doc = mock.MagicMock()
|
||||
user_state_coll.document.return_value = user_app_doc
|
||||
users_coll = mock.MagicMock()
|
||||
user_app_doc.collection.return_value = users_coll
|
||||
user_doc_ref = mock.MagicMock()
|
||||
users_coll.document.return_value = user_doc_ref
|
||||
user_doc_ref.get = mock.AsyncMock(return_value=user_doc)
|
||||
|
||||
app_doc_in_root = mock.MagicMock()
|
||||
sessions_coll.document.return_value = app_doc_in_root
|
||||
users_coll = mock.MagicMock()
|
||||
app_doc_in_root.collection.return_value = users_coll
|
||||
user_doc_in_users = mock.MagicMock()
|
||||
users_coll.document.return_value = user_doc_in_users
|
||||
sessions_subcoll = mock.MagicMock()
|
||||
user_doc_in_users.collection.return_value = sessions_subcoll
|
||||
sessions_query = mock.MagicMock()
|
||||
sessions_subcoll.where.return_value = sessions_query
|
||||
sessions_query.get = mock.AsyncMock(return_value=[session_doc])
|
||||
|
||||
response = await service.list_sessions(app_name=app_name, user_id=user_id)
|
||||
|
||||
assert len(response.sessions) == 1
|
||||
session = response.sessions[0]
|
||||
assert session.id == "session1"
|
||||
assert session.state["session_key"] == "session_val"
|
||||
assert "_app_app_key" not in session.state
|
||||
assert "_user_user_key" not in session.state
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.integrations.gcs import client
|
||||
from google.auth.credentials import Credentials
|
||||
from google.cloud import storage
|
||||
|
||||
|
||||
def test_get_gcs_client():
|
||||
"""Test get_gcs_client function."""
|
||||
with mock.patch.object(storage, "Client", autospec=True) as MockGCSClient:
|
||||
mock_creds = mock.create_autospec(Credentials, instance=True)
|
||||
client.get_gcs_client(project="test-project", credentials=mock_creds)
|
||||
MockGCSClient.assert_called_once_with(
|
||||
project="test-project",
|
||||
credentials=mock_creds,
|
||||
client_info=mock.ANY,
|
||||
)
|
||||
|
||||
|
||||
def test_get_gcs_client_cache():
|
||||
"""Test get_gcs_client caches and reuses the client instance."""
|
||||
client._client_cache.clear() # pylint: disable=protected-access
|
||||
|
||||
with mock.patch.object(storage, "Client", autospec=True) as MockGCSClient:
|
||||
mock_creds = mock.create_autospec(Credentials, instance=True)
|
||||
|
||||
# First call - cache miss
|
||||
client1 = client.get_gcs_client(
|
||||
project="test-project", credentials=mock_creds
|
||||
)
|
||||
|
||||
# Second call - cache hit
|
||||
client2 = client.get_gcs_client(
|
||||
project="test-project", credentials=mock_creds
|
||||
)
|
||||
|
||||
assert client1 is client2
|
||||
MockGCSClient.assert_called_once_with(
|
||||
project="test-project",
|
||||
credentials=mock_creds,
|
||||
client_info=mock.ANY,
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.integrations.gcs import admin_tool
|
||||
from google.adk.integrations.gcs import client
|
||||
from google.auth.credentials import Credentials
|
||||
|
||||
|
||||
def test_list_buckets():
|
||||
"""Test list_buckets function."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_bucket.name = "test-bucket"
|
||||
mock_client.list_buckets.return_value = [mock_bucket]
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = admin_tool.list_buckets(
|
||||
project_id="test-project", credentials=creds
|
||||
)
|
||||
assert result == {
|
||||
"status": "SUCCESS",
|
||||
"results": ["test-bucket"],
|
||||
}
|
||||
|
||||
|
||||
def test_list_buckets_pagination():
|
||||
"""Test list_buckets function with pagination."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_bucket.name = "test-bucket"
|
||||
mock_buckets = mock.MagicMock()
|
||||
mock_buckets.pages = iter([[mock_bucket]])
|
||||
mock_buckets.next_page_token = "next-page-token"
|
||||
mock_client.list_buckets.return_value = mock_buckets
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = admin_tool.list_buckets(
|
||||
project_id="test-project",
|
||||
credentials=creds,
|
||||
page_size=1,
|
||||
page_token="token",
|
||||
)
|
||||
assert result == {
|
||||
"status": "SUCCESS",
|
||||
"results": ["test-bucket"],
|
||||
"next_page_token": "next-page-token",
|
||||
}
|
||||
mock_client.list_buckets.assert_called_once_with(
|
||||
max_results=1, page_token="token"
|
||||
)
|
||||
|
||||
|
||||
def test_create_bucket():
|
||||
"""Test create_bucket function."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket_obj = mock.MagicMock()
|
||||
mock_client.bucket.return_value = mock_bucket_obj
|
||||
mock_new_bucket = mock.MagicMock()
|
||||
mock_new_bucket.name = "test-bucket"
|
||||
mock_client.create_bucket.return_value = mock_new_bucket
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = admin_tool.create_bucket(
|
||||
project_id="test-project", bucket_name="test-bucket", credentials=creds
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
mock_client.create_bucket.assert_called_once_with(
|
||||
mock_bucket_obj, location=None
|
||||
)
|
||||
|
||||
|
||||
def test_update_bucket():
|
||||
"""Test update_bucket function."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_bucket.name = "test-bucket"
|
||||
mock_bucket.iam_configuration = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = admin_tool.update_bucket(
|
||||
bucket_name="test-bucket",
|
||||
credentials=creds,
|
||||
versioning_enabled=True,
|
||||
uniform_bucket_level_access_enabled=True,
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert mock_bucket.versioning_enabled is True
|
||||
assert (
|
||||
mock_bucket.iam_configuration.uniform_bucket_level_access_enabled
|
||||
is True
|
||||
)
|
||||
mock_bucket.patch.assert_called_once()
|
||||
|
||||
|
||||
def test_delete_bucket():
|
||||
"""Test delete_bucket function."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = admin_tool.delete_bucket(
|
||||
bucket_name="test-bucket", credentials=creds
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
mock_bucket.delete.assert_called_once()
|
||||
@@ -0,0 +1,67 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
# # Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.integrations.gcs.gcs_credentials import GCS_DEFAULT_SCOPE
|
||||
from google.adk.integrations.gcs.gcs_credentials import GCSCredentialsConfig
|
||||
from google.auth.credentials import Credentials
|
||||
import google.oauth2.credentials
|
||||
import pytest
|
||||
|
||||
|
||||
class TestGCSCredentials:
|
||||
"""Test suite for GCS credentials configuration validation."""
|
||||
|
||||
def test_gcs_credentials_config_client_id_secret(self):
|
||||
"""Test GCSCredentialsConfig with client_id and client_secret."""
|
||||
config = GCSCredentialsConfig(client_id="abc", client_secret="def")
|
||||
assert config.client_id == "abc"
|
||||
assert config.client_secret == "def"
|
||||
assert config.scopes == GCS_DEFAULT_SCOPE
|
||||
assert config.credentials is None
|
||||
|
||||
def test_gcs_credentials_config_existing_creds(self):
|
||||
"""Test GCSCredentialsConfig with existing generic credentials."""
|
||||
mock_creds = mock.create_autospec(Credentials, instance=True)
|
||||
config = GCSCredentialsConfig(credentials=mock_creds)
|
||||
assert config.credentials == mock_creds
|
||||
assert config.client_id is None
|
||||
assert config.client_secret is None
|
||||
|
||||
def test_gcs_credentials_config_oauth2_creds(self):
|
||||
"""Test GCSCredentialsConfig with existing OAuth2 credentials."""
|
||||
mock_creds = mock.create_autospec(
|
||||
google.oauth2.credentials.Credentials, instance=True
|
||||
)
|
||||
mock_creds.client_id = "oauth_client_id"
|
||||
mock_creds.client_secret = "oauth_client_secret"
|
||||
mock_creds.scopes = ["fake_scope"]
|
||||
config = GCSCredentialsConfig(credentials=mock_creds)
|
||||
assert config.client_id == "oauth_client_id"
|
||||
assert config.client_secret == "oauth_client_secret"
|
||||
assert config.scopes == ["fake_scope"]
|
||||
|
||||
def test_gcs_credentials_config_validation_errors(self):
|
||||
"""Test GCSCredentialsConfig validation errors."""
|
||||
with pytest.raises(ValueError):
|
||||
GCSCredentialsConfig()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
GCSCredentialsConfig(client_id="abc")
|
||||
|
||||
mock_creds = mock.create_autospec(Credentials, instance=True)
|
||||
with pytest.raises(ValueError):
|
||||
GCSCredentialsConfig(
|
||||
credentials=mock_creds, client_id="abc", client_secret="def"
|
||||
)
|
||||
@@ -0,0 +1,373 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.integrations.gcs import client
|
||||
from google.adk.integrations.gcs import storage_tool
|
||||
from google.auth.credentials import Credentials
|
||||
|
||||
|
||||
def test_get_bucket():
|
||||
"""Test get_bucket function."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
setattr(
|
||||
mock_bucket,
|
||||
"_properties",
|
||||
{
|
||||
"bucket_id": "test-bucket-id",
|
||||
"bucket_name": "test-bucket",
|
||||
"location": "US",
|
||||
"storage_class": "STANDARD",
|
||||
"time_created": "2024-01-01",
|
||||
"updated": "2024-01-02",
|
||||
"labels": {"env": "test"},
|
||||
},
|
||||
)
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = storage_tool.get_bucket(
|
||||
bucket_name="test-bucket", credentials=creds
|
||||
)
|
||||
expected_result = getattr(mock_bucket, "_properties", {}).copy()
|
||||
assert result == {"status": "SUCCESS", "results": expected_result}
|
||||
|
||||
|
||||
def test_get_bucket_with_properties():
|
||||
"""Test get_bucket function when bucket has raw _properties populated."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
setattr(
|
||||
mock_bucket,
|
||||
"_properties",
|
||||
{
|
||||
"kind": "storage#bucket",
|
||||
"id": "test-bucket-id",
|
||||
"name": "test-bucket",
|
||||
"location": "US",
|
||||
"storageClass": "STANDARD",
|
||||
"timeCreated": "2024-01-01",
|
||||
"updated": "2024-01-02",
|
||||
"labels": {"env": "test"},
|
||||
"locationType": "region",
|
||||
"etag": "etag-val",
|
||||
"metageneration": 2,
|
||||
"versioning": {"enabled": True},
|
||||
"iamConfiguration": {"uniformBucketLevelAccess": {"enabled": True}},
|
||||
},
|
||||
)
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = storage_tool.get_bucket(
|
||||
bucket_name="test-bucket", credentials=creds
|
||||
)
|
||||
expected_result = getattr(mock_bucket, "_properties", {}).copy()
|
||||
assert result == {"status": "SUCCESS", "results": expected_result}
|
||||
|
||||
|
||||
def test_list_objects():
|
||||
"""Test list_objects function."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
mock_blob = mock.MagicMock()
|
||||
mock_blob.name = "test-object"
|
||||
mock_bucket.list_blobs.return_value = [mock_blob]
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = storage_tool.list_objects(
|
||||
bucket_name="test-bucket", credentials=creds
|
||||
)
|
||||
assert result == {
|
||||
"status": "SUCCESS",
|
||||
"results": ["test-object"],
|
||||
}
|
||||
|
||||
|
||||
def test_list_objects_pagination():
|
||||
"""Test list_objects function with pagination."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
mock_blob = mock.MagicMock()
|
||||
mock_blob.name = "test-object"
|
||||
mock_blobs = mock.MagicMock()
|
||||
mock_blobs.pages = iter([[mock_blob]])
|
||||
mock_blobs.next_page_token = "next-page-token"
|
||||
mock_bucket.list_blobs.return_value = mock_blobs
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = storage_tool.list_objects(
|
||||
bucket_name="test-bucket",
|
||||
credentials=creds,
|
||||
page_size=1,
|
||||
page_token="token",
|
||||
)
|
||||
assert result == {
|
||||
"status": "SUCCESS",
|
||||
"results": ["test-object"],
|
||||
"next_page_token": "next-page-token",
|
||||
}
|
||||
mock_bucket.list_blobs.assert_called_once_with(
|
||||
max_results=1, page_token="token"
|
||||
)
|
||||
|
||||
|
||||
def test_get_object_metadata():
|
||||
"""Test get_object_metadata function."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
mock_blob = mock.MagicMock()
|
||||
mock_bucket.get_blob.return_value = mock_blob
|
||||
setattr(
|
||||
mock_blob,
|
||||
"_properties",
|
||||
{
|
||||
"kind": "storage#object",
|
||||
"id": "test-bucket/test-object/1",
|
||||
"name": "test-object",
|
||||
"bucket": "test-bucket",
|
||||
"size": "1024",
|
||||
"contentType": "text/plain",
|
||||
"timeCreated": "2024-01-01",
|
||||
"updated": "2024-01-02",
|
||||
"md5Hash": "hash",
|
||||
"metadata": {"key": "value"},
|
||||
},
|
||||
)
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = storage_tool.get_object_metadata(
|
||||
bucket_name="test-bucket",
|
||||
object_name="test-object",
|
||||
credentials=creds,
|
||||
generation=1,
|
||||
)
|
||||
expected_result = getattr(mock_blob, "_properties", {}).copy()
|
||||
assert result == {"status": "SUCCESS", "results": expected_result}
|
||||
mock_bucket.get_blob.assert_called_once_with("test-object", generation=1)
|
||||
|
||||
|
||||
def test_get_object_metadata_not_found():
|
||||
"""Test get_object_metadata function when object is not found."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
mock_bucket.get_blob.return_value = None
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = storage_tool.get_object_metadata(
|
||||
bucket_name="test-bucket",
|
||||
object_name="non-existent",
|
||||
credentials=creds,
|
||||
)
|
||||
assert result["status"] == "ERROR"
|
||||
assert "not found" in result["error_details"]
|
||||
mock_bucket.get_blob.assert_called_once_with("non-existent")
|
||||
|
||||
|
||||
def test_create_object():
|
||||
"""Test create_object function."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
mock_blob = mock.MagicMock()
|
||||
mock_bucket.blob.return_value = mock_blob
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = storage_tool.create_object(
|
||||
bucket_name="test-bucket",
|
||||
object_name="test-object",
|
||||
data="data",
|
||||
credentials=creds,
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
mock_blob.upload_from_string.assert_called_once_with("data")
|
||||
|
||||
|
||||
def test_create_object_from_file():
|
||||
"""Test create_object function using source_file_path."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
mock_blob = mock.MagicMock()
|
||||
mock_bucket.blob.return_value = mock_blob
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = storage_tool.create_object(
|
||||
bucket_name="test-bucket",
|
||||
object_name="test-object",
|
||||
source_file_path="path/to/file.txt",
|
||||
credentials=creds,
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
mock_blob.upload_from_filename.assert_called_once_with("path/to/file.txt")
|
||||
|
||||
|
||||
def test_create_object_no_data():
|
||||
"""Test create_object function when neither data nor source_file_path is provided."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
mock_blob = mock.MagicMock()
|
||||
mock_bucket.blob.return_value = mock_blob
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = storage_tool.create_object(
|
||||
bucket_name="test-bucket",
|
||||
object_name="test-object",
|
||||
credentials=creds,
|
||||
)
|
||||
assert result["status"] == "ERROR"
|
||||
assert "must be provided" in result["error_details"]
|
||||
|
||||
|
||||
def test_get_object_data():
|
||||
"""Test get_object_data function."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
mock_blob = mock.MagicMock()
|
||||
mock_bucket.get_blob.return_value = mock_blob
|
||||
mock_blob.download_as_bytes.return_value = b"content"
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = storage_tool.get_object_data(
|
||||
bucket_name="test-bucket",
|
||||
object_name="test-object",
|
||||
credentials=creds,
|
||||
generation=1,
|
||||
)
|
||||
assert result == {
|
||||
"status": "SUCCESS",
|
||||
"results": "content",
|
||||
"encoding": "text",
|
||||
}
|
||||
mock_bucket.get_blob.assert_called_once_with("test-object", generation=1)
|
||||
|
||||
|
||||
def test_get_object_data_no_generation():
|
||||
"""Test get_object_data function without generation parameter."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
mock_blob = mock.MagicMock()
|
||||
mock_bucket.get_blob.return_value = mock_blob
|
||||
mock_blob.download_as_bytes.return_value = b"\xff\xff"
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = storage_tool.get_object_data(
|
||||
bucket_name="test-bucket",
|
||||
object_name="test-object",
|
||||
credentials=creds,
|
||||
)
|
||||
assert result == {
|
||||
"status": "SUCCESS",
|
||||
"results": "//8=",
|
||||
"encoding": "base64",
|
||||
}
|
||||
mock_bucket.get_blob.assert_called_once_with("test-object")
|
||||
|
||||
|
||||
def test_get_object_data_to_file():
|
||||
"""Test get_object_data function downloading directly to destination_file_path."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
mock_blob = mock.MagicMock()
|
||||
mock_bucket.get_blob.return_value = mock_blob
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = storage_tool.get_object_data(
|
||||
bucket_name="test-bucket",
|
||||
object_name="test-object",
|
||||
destination_file_path="path/to/download.txt",
|
||||
credentials=creds,
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
mock_blob.download_to_filename.assert_called_once_with(
|
||||
"path/to/download.txt"
|
||||
)
|
||||
|
||||
|
||||
def test_delete_objects():
|
||||
"""Test delete_objects function."""
|
||||
with mock.patch.object(
|
||||
client, "get_gcs_client", autospec=True
|
||||
) as mock_get_client:
|
||||
mock_client = mock.MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.get_bucket.return_value = mock_bucket
|
||||
|
||||
creds = mock.create_autospec(Credentials, instance=True)
|
||||
result = storage_tool.delete_objects(
|
||||
bucket_name="test-bucket",
|
||||
object_names=["test-object"],
|
||||
credentials=creds,
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
mock_bucket.delete_blobs.assert_called_once_with(blobs=["test-object"])
|
||||
@@ -0,0 +1,111 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from google.adk.integrations.gcs import GCSCredentialsConfig
|
||||
from google.adk.integrations.gcs.admin_toolset import GCSAdminToolset
|
||||
from google.adk.integrations.gcs.storage_toolset import DEFAULT_GCS_TOOL_NAME_PREFIX
|
||||
from google.adk.integrations.gcs.storage_toolset import GCSToolset
|
||||
from google.adk.tools.google_tool import GoogleTool
|
||||
import pytest
|
||||
|
||||
|
||||
def test_gcs_toolset_name_prefix():
|
||||
"""Test GCS toolset name prefix."""
|
||||
credentials_config = GCSCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = GCSToolset(credentials_config=credentials_config)
|
||||
assert toolset.tool_name_prefix == DEFAULT_GCS_TOOL_NAME_PREFIX
|
||||
|
||||
admin_toolset = GCSAdminToolset(credentials_config=credentials_config)
|
||||
assert admin_toolset.tool_name_prefix == DEFAULT_GCS_TOOL_NAME_PREFIX
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gcs_toolset_tools_default():
|
||||
"""Test default GCS toolset."""
|
||||
credentials_config = GCSCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = GCSToolset(credentials_config=credentials_config)
|
||||
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == 4
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set([
|
||||
"get_bucket",
|
||||
"get_object_data",
|
||||
"get_object_metadata",
|
||||
"list_objects",
|
||||
])
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gcs_admin_toolset_tools_default():
|
||||
"""Test default GCS admin toolset."""
|
||||
credentials_config = GCSCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = GCSAdminToolset(credentials_config=credentials_config)
|
||||
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == 1
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set([
|
||||
"list_buckets",
|
||||
])
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"selected_tools, expected_count",
|
||||
[
|
||||
pytest.param(None, 4, id="None"),
|
||||
pytest.param(["get_bucket"], 1, id="bucket-get"),
|
||||
pytest.param(
|
||||
["list_objects", "get_object_metadata"], 2, id="object-metadata"
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_gcs_toolset_tools_selective(selected_tools, expected_count):
|
||||
"""Test GCS toolset with filter."""
|
||||
credentials_config = GCSCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = GCSToolset(
|
||||
credentials_config=credentials_config, tool_filter=selected_tools
|
||||
)
|
||||
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == expected_count
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
if selected_tools is not None:
|
||||
expected_tool_names = set(selected_tools)
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
@@ -0,0 +1,153 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from google.adk.integrations.gcs import GCSAdminToolset
|
||||
from google.adk.integrations.gcs import GCSCredentialsConfig
|
||||
from google.adk.integrations.gcs import GCSToolset
|
||||
from google.adk.integrations.gcs.settings import Capabilities
|
||||
from google.adk.integrations.gcs.settings import GCSToolSettings
|
||||
from google.adk.tools.google_tool import GoogleTool
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gcs_toolset_tools_default():
|
||||
"""Test default GCS toolset (READ_ONLY)."""
|
||||
credentials_config = GCSCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = GCSToolset(
|
||||
credentials_config=credentials_config, gcs_tool_settings=None
|
||||
)
|
||||
assert isinstance(toolset._tool_settings, GCSToolSettings)
|
||||
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
assert len(tools) == 4
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = {
|
||||
"get_bucket",
|
||||
"get_object_data",
|
||||
"get_object_metadata",
|
||||
"list_objects",
|
||||
}
|
||||
actual_tool_names = {tool.name for tool in tools}
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gcs_toolset_tools_read_write():
|
||||
"""Test GCS toolset with READ_WRITE capability."""
|
||||
credentials_config = GCSCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
settings = GCSToolSettings(capabilities=[Capabilities.READ_WRITE])
|
||||
toolset = GCSToolset(
|
||||
credentials_config=credentials_config, gcs_tool_settings=settings
|
||||
)
|
||||
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
assert len(tools) == 6
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = {
|
||||
"get_bucket",
|
||||
"get_object_data",
|
||||
"get_object_metadata",
|
||||
"list_objects",
|
||||
"create_object",
|
||||
"delete_objects",
|
||||
}
|
||||
actual_tool_names = {tool.name for tool in tools}
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gcs_admin_toolset_tools_default():
|
||||
"""Test default GCS admin toolset (READ_ONLY)."""
|
||||
credentials_config = GCSCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = GCSAdminToolset(
|
||||
credentials_config=credentials_config, gcs_tool_settings=None
|
||||
)
|
||||
assert isinstance(toolset._tool_settings, GCSToolSettings)
|
||||
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
assert len(tools) == 1
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = {
|
||||
"list_buckets",
|
||||
}
|
||||
actual_tool_names = {tool.name for tool in tools}
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gcs_admin_toolset_tools_read_write():
|
||||
"""Test GCS admin toolset with READ_WRITE capability."""
|
||||
credentials_config = GCSCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
settings = GCSToolSettings(capabilities=[Capabilities.READ_WRITE])
|
||||
toolset = GCSAdminToolset(
|
||||
credentials_config=credentials_config, gcs_tool_settings=settings
|
||||
)
|
||||
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
assert len(tools) == 4
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = {
|
||||
"list_buckets",
|
||||
"create_bucket",
|
||||
"update_bucket",
|
||||
"delete_bucket",
|
||||
}
|
||||
actual_tool_names = {tool.name for tool in tools}
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"selected_tools, expected_count",
|
||||
[
|
||||
pytest.param(None, 4, id="None"),
|
||||
pytest.param(["get_bucket", "list_objects"], 2, id="read-subset"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_gcs_toolset_tools_selective(selected_tools, expected_count):
|
||||
"""Test GCS toolset with filter."""
|
||||
credentials_config = GCSCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = GCSToolset(
|
||||
credentials_config=credentials_config, tool_filter=selected_tools
|
||||
)
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
assert len(tools) == expected_count
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
if selected_tools is not None:
|
||||
expected_tool_names = set(selected_tools)
|
||||
actual_tool_names = {tool.name for tool in tools}
|
||||
assert actual_tool_names == expected_tool_names
|
||||
@@ -0,0 +1,101 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from google.adk.integrations.langchain import LangchainTool
|
||||
from langchain_core.tools import tool
|
||||
from langchain_core.tools.structured import StructuredTool
|
||||
from pydantic import BaseModel
|
||||
import pytest
|
||||
|
||||
|
||||
@tool
|
||||
async def async_add_with_annotation(x, y) -> int:
|
||||
"""Adds two numbers"""
|
||||
return x + y
|
||||
|
||||
|
||||
@tool
|
||||
def sync_add_with_annotation(x, y) -> int:
|
||||
"""Adds two numbers"""
|
||||
return x + y
|
||||
|
||||
|
||||
async def async_add(x, y) -> int:
|
||||
return x + y
|
||||
|
||||
|
||||
def sync_add(x, y) -> int:
|
||||
return x + y
|
||||
|
||||
|
||||
class AddSchema(BaseModel):
|
||||
x: int
|
||||
y: int
|
||||
|
||||
|
||||
test_langchain_async_add_tool = StructuredTool.from_function(
|
||||
async_add,
|
||||
name="add",
|
||||
description="Adds two numbers",
|
||||
args_schema=AddSchema,
|
||||
)
|
||||
|
||||
test_langchain_sync_add_tool = StructuredTool.from_function(
|
||||
sync_add,
|
||||
name="add",
|
||||
description="Adds two numbers",
|
||||
args_schema=AddSchema,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_async_function_works():
|
||||
"""Test that passing a raw async function to LangchainTool works correctly."""
|
||||
langchain_tool = LangchainTool(tool=test_langchain_async_add_tool)
|
||||
result = await langchain_tool.run_async(
|
||||
args={"x": 1, "y": 3}, tool_context=MagicMock()
|
||||
)
|
||||
assert result == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_sync_function_works():
|
||||
"""Test that passing a raw sync function to LangchainTool works correctly."""
|
||||
langchain_tool = LangchainTool(tool=test_langchain_sync_add_tool)
|
||||
result = await langchain_tool.run_async(
|
||||
args={"x": 1, "y": 3}, tool_context=MagicMock()
|
||||
)
|
||||
assert result == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_async_function_with_annotation_works():
|
||||
"""Test that passing a raw async function to LangchainTool works correctly."""
|
||||
langchain_tool = LangchainTool(tool=async_add_with_annotation)
|
||||
result = await langchain_tool.run_async(
|
||||
args={"x": 1, "y": 3}, tool_context=MagicMock()
|
||||
)
|
||||
assert result == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_sync_function_with_annotation_works():
|
||||
"""Test that passing a raw sync function to LangchainTool works correctly."""
|
||||
langchain_tool = LangchainTool(tool=sync_add_with_annotation)
|
||||
result = await langchain_tool.run_async(
|
||||
args={"x": 1, "y": 3}, tool_context=MagicMock()
|
||||
)
|
||||
assert result == 4
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,252 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for the ParameterManagerClient."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.api_core.gapic_v1 import client_info
|
||||
from google.oauth2.credentials import Credentials
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("google.cloud.parametermanager_v1")
|
||||
|
||||
from google.adk.integrations.parameter_manager.parameter_client import ParameterManagerClient
|
||||
from google.adk.integrations.parameter_manager.parameter_client import USER_AGENT
|
||||
|
||||
|
||||
class TestParameterManagerClient:
|
||||
"""Tests for the ParameterManagerClient class."""
|
||||
|
||||
@patch("google.cloud.parametermanager_v1.ParameterManagerClient")
|
||||
@patch(
|
||||
"google.adk.integrations.parameter_manager.parameter_client.default_service_credential"
|
||||
)
|
||||
def test_init_with_default_credentials(
|
||||
self, mock_default_service_credential, mock_pm_client_class
|
||||
):
|
||||
"""Test initialization with default credentials."""
|
||||
# Setup
|
||||
mock_credentials = MagicMock()
|
||||
mock_default_service_credential.return_value = (
|
||||
mock_credentials,
|
||||
"test-project",
|
||||
)
|
||||
|
||||
# Execute
|
||||
client = ParameterManagerClient()
|
||||
|
||||
# Verify
|
||||
mock_default_service_credential.assert_called_once_with(
|
||||
scopes=["https://www.googleapis.com/auth/cloud-platform"]
|
||||
)
|
||||
mock_pm_client_class.assert_called_once()
|
||||
call_kwargs = mock_pm_client_class.call_args.kwargs
|
||||
assert call_kwargs["credentials"] == mock_credentials
|
||||
assert call_kwargs["client_options"] is None
|
||||
assert call_kwargs["client_info"].user_agent == USER_AGENT
|
||||
assert client._credentials == mock_credentials
|
||||
assert client._client == mock_pm_client_class.return_value
|
||||
|
||||
@patch("google.cloud.parametermanager_v1.ParameterManagerClient")
|
||||
@patch("google.oauth2.service_account.Credentials.from_service_account_info")
|
||||
def test_init_with_service_account_json(
|
||||
self, mock_from_service_account_info, mock_pm_client_class
|
||||
):
|
||||
"""Test initialization with service account JSON."""
|
||||
# Setup
|
||||
mock_credentials = MagicMock()
|
||||
mock_from_service_account_info.return_value = mock_credentials
|
||||
service_account_json = json.dumps({
|
||||
"type": "service_account",
|
||||
"project_id": "test-project",
|
||||
"private_key_id": "key-id",
|
||||
"private_key": "private-key",
|
||||
"client_email": "test@example.com",
|
||||
})
|
||||
|
||||
# Execute
|
||||
client = ParameterManagerClient(service_account_json=service_account_json)
|
||||
|
||||
# Verify
|
||||
mock_from_service_account_info.assert_called_once_with(
|
||||
json.loads(service_account_json)
|
||||
)
|
||||
mock_pm_client_class.assert_called_once()
|
||||
call_kwargs = mock_pm_client_class.call_args.kwargs
|
||||
assert call_kwargs["credentials"] == mock_credentials
|
||||
assert call_kwargs["client_options"] is None
|
||||
assert call_kwargs["client_info"].user_agent == USER_AGENT
|
||||
assert client._credentials == mock_credentials
|
||||
assert client._client == mock_pm_client_class.return_value
|
||||
|
||||
@patch("google.cloud.parametermanager_v1.ParameterManagerClient")
|
||||
def test_init_with_auth_token(self, mock_pm_client_class):
|
||||
"""Test initialization with auth token."""
|
||||
auth_token = "test-token"
|
||||
|
||||
client = ParameterManagerClient(auth_token=auth_token)
|
||||
|
||||
mock_pm_client_class.assert_called_once()
|
||||
call_kwargs = mock_pm_client_class.call_args.kwargs
|
||||
assert isinstance(call_kwargs["credentials"], Credentials)
|
||||
assert call_kwargs["credentials"].token == auth_token
|
||||
assert call_kwargs["client_options"] is None
|
||||
assert call_kwargs["client_info"].user_agent == USER_AGENT
|
||||
assert isinstance(client._credentials, Credentials)
|
||||
assert client._credentials.token == auth_token
|
||||
assert client._client == mock_pm_client_class.return_value
|
||||
|
||||
@patch("google.cloud.parametermanager_v1.ParameterManagerClient")
|
||||
@patch(
|
||||
"google.adk.integrations.parameter_manager.parameter_client.default_service_credential"
|
||||
)
|
||||
@patch(
|
||||
"google.adk.integrations.parameter_manager.parameter_client.get_api_endpoint"
|
||||
)
|
||||
def test_init_with_location(
|
||||
self,
|
||||
mock_get_api_endpoint,
|
||||
mock_default_service_credential,
|
||||
mock_pm_client_class,
|
||||
):
|
||||
"""Test initialization with a specific location."""
|
||||
# Setup
|
||||
mock_credentials = MagicMock()
|
||||
mock_default_service_credential.return_value = (
|
||||
mock_credentials,
|
||||
"test-project",
|
||||
)
|
||||
location = "us-central1"
|
||||
mock_get_api_endpoint.return_value = "resolved-endpoint"
|
||||
|
||||
# Execute
|
||||
ParameterManagerClient(location=location)
|
||||
|
||||
# Verify
|
||||
mock_pm_client_class.assert_called_once()
|
||||
call_kwargs = mock_pm_client_class.call_args.kwargs
|
||||
assert call_kwargs["credentials"] == mock_credentials
|
||||
assert call_kwargs["client_options"] == {
|
||||
"api_endpoint": "resolved-endpoint"
|
||||
}
|
||||
assert call_kwargs["client_info"].user_agent == USER_AGENT
|
||||
mock_get_api_endpoint.assert_called_once_with(
|
||||
location,
|
||||
"parametermanager.{location}.rep.googleapis.com",
|
||||
"parametermanager.{location}.rep.mtls.googleapis.com",
|
||||
)
|
||||
|
||||
@patch(
|
||||
"google.adk.integrations.parameter_manager.parameter_client.default_service_credential"
|
||||
)
|
||||
def test_init_with_default_credentials_error(
|
||||
self, mock_default_service_credential
|
||||
):
|
||||
"""Test initialization with default credentials that fails."""
|
||||
# Setup
|
||||
mock_default_service_credential.side_effect = Exception("Auth error")
|
||||
|
||||
# Execute and verify
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="error occurred while trying to use default credentials",
|
||||
):
|
||||
ParameterManagerClient()
|
||||
|
||||
def test_init_with_invalid_service_account_json(self):
|
||||
"""Test initialization with invalid service account JSON."""
|
||||
# Execute and verify
|
||||
with pytest.raises(ValueError, match="Invalid service account JSON"):
|
||||
ParameterManagerClient(service_account_json="invalid-json")
|
||||
|
||||
def test_init_with_both_service_account_json_and_auth_token(self):
|
||||
"""Test initialization rejects conflicting credential inputs."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Must provide either 'service_account_json' or 'auth_token', not"
|
||||
" both."
|
||||
),
|
||||
):
|
||||
ParameterManagerClient(
|
||||
service_account_json=json.dumps({"type": "service_account"}),
|
||||
auth_token="test-token",
|
||||
)
|
||||
|
||||
@patch("google.cloud.parametermanager_v1.ParameterManagerClient")
|
||||
@patch(
|
||||
"google.adk.integrations.parameter_manager.parameter_client.default_service_credential"
|
||||
)
|
||||
def test_get_parameter(
|
||||
self, mock_default_service_credential, mock_pm_client_class
|
||||
):
|
||||
"""Test getting a parameter."""
|
||||
# Setup
|
||||
mock_credentials = MagicMock()
|
||||
mock_default_service_credential.return_value = (
|
||||
mock_credentials,
|
||||
"test-project",
|
||||
)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_pm_client_class.return_value = mock_client
|
||||
mock_response = MagicMock()
|
||||
mock_response.rendered_payload.decode.return_value = "parameter-value"
|
||||
mock_client.render_parameter_version.return_value = mock_response
|
||||
|
||||
# Execute
|
||||
client = ParameterManagerClient()
|
||||
result = client.get_parameter(
|
||||
"projects/test-project/locations/global/parameters/test-param/versions/latest"
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result == "parameter-value"
|
||||
mock_response.rendered_payload.decode.assert_called_once_with("UTF-8")
|
||||
# Verify render_parameter_version was called with correct request object
|
||||
call_kwargs = mock_client.render_parameter_version.call_args.kwargs
|
||||
assert (
|
||||
call_kwargs["request"].name
|
||||
== "projects/test-project/locations/global/parameters/test-param/versions/latest"
|
||||
)
|
||||
mock_response.rendered_payload.decode.assert_called_once_with("UTF-8")
|
||||
|
||||
@patch("google.cloud.parametermanager_v1.ParameterManagerClient")
|
||||
@patch(
|
||||
"google.adk.integrations.parameter_manager.parameter_client.default_service_credential"
|
||||
)
|
||||
def test_get_parameter_error(
|
||||
self, mock_default_service_credential, mock_pm_client_class
|
||||
):
|
||||
"""Test getting a parameter that fails."""
|
||||
# Setup
|
||||
mock_credentials = MagicMock()
|
||||
mock_default_service_credential.return_value = (
|
||||
mock_credentials,
|
||||
"test-project",
|
||||
)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_pm_client_class.return_value = mock_client
|
||||
mock_client.render_parameter_version.side_effect = Exception("API error")
|
||||
|
||||
# Execute and verify
|
||||
client = ParameterManagerClient()
|
||||
with pytest.raises(Exception, match="API error"):
|
||||
client.get_parameter(
|
||||
"projects/test-project/locations/global/parameters/test-param/versions/latest"
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,247 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for the SecretManagerClient."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.integrations.secret_manager.secret_client import SecretManagerClient
|
||||
from google.adk.integrations.secret_manager.secret_client import USER_AGENT
|
||||
from google.api_core.gapic_v1 import client_info
|
||||
from google.oauth2.credentials import Credentials
|
||||
import pytest
|
||||
|
||||
import google
|
||||
|
||||
|
||||
class TestSecretManagerClient:
|
||||
"""Tests for the SecretManagerClient class."""
|
||||
|
||||
@patch("google.cloud.secretmanager.SecretManagerServiceClient")
|
||||
@patch(
|
||||
"google.adk.integrations.secret_manager.secret_client.default_service_credential"
|
||||
)
|
||||
def test_init_with_default_credentials(
|
||||
self, mock_default_service_credential, mock_secret_manager_client
|
||||
):
|
||||
"""Test initialization with default credentials."""
|
||||
# Setup
|
||||
mock_credentials = MagicMock()
|
||||
mock_default_service_credential.return_value = (
|
||||
mock_credentials,
|
||||
"test-project",
|
||||
)
|
||||
|
||||
# Execute
|
||||
client = SecretManagerClient()
|
||||
|
||||
# Verify
|
||||
mock_default_service_credential.assert_called_once_with(
|
||||
scopes=["https://www.googleapis.com/auth/cloud-platform"]
|
||||
)
|
||||
mock_secret_manager_client.assert_called_once()
|
||||
call_kwargs = mock_secret_manager_client.call_args.kwargs
|
||||
assert call_kwargs["credentials"] == mock_credentials
|
||||
assert call_kwargs["client_options"] is None
|
||||
assert call_kwargs["client_info"].user_agent == USER_AGENT
|
||||
assert client._credentials == mock_credentials
|
||||
assert client._client == mock_secret_manager_client.return_value
|
||||
|
||||
@patch("google.cloud.secretmanager.SecretManagerServiceClient")
|
||||
@patch("google.oauth2.service_account.Credentials.from_service_account_info")
|
||||
def test_init_with_service_account_json(
|
||||
self, mock_from_service_account_info, mock_secret_manager_client
|
||||
):
|
||||
"""Test initialization with service account JSON."""
|
||||
# Setup
|
||||
mock_credentials = MagicMock()
|
||||
mock_from_service_account_info.return_value = mock_credentials
|
||||
service_account_json = json.dumps({
|
||||
"type": "service_account",
|
||||
"project_id": "test-project",
|
||||
"private_key_id": "key-id",
|
||||
"private_key": "private-key",
|
||||
"client_email": "test@example.com",
|
||||
})
|
||||
|
||||
# Execute
|
||||
client = SecretManagerClient(service_account_json=service_account_json)
|
||||
|
||||
# Verify
|
||||
mock_from_service_account_info.assert_called_once_with(
|
||||
json.loads(service_account_json)
|
||||
)
|
||||
mock_secret_manager_client.assert_called_once()
|
||||
call_kwargs = mock_secret_manager_client.call_args.kwargs
|
||||
assert call_kwargs["credentials"] == mock_credentials
|
||||
assert call_kwargs["client_options"] is None
|
||||
assert call_kwargs["client_info"].user_agent == USER_AGENT
|
||||
assert client._credentials == mock_credentials
|
||||
assert client._client == mock_secret_manager_client.return_value
|
||||
|
||||
@patch("google.cloud.secretmanager.SecretManagerServiceClient")
|
||||
def test_init_with_auth_token(self, mock_secret_manager_client):
|
||||
"""Test initialization with auth token."""
|
||||
auth_token = "test-token"
|
||||
|
||||
client = SecretManagerClient(auth_token=auth_token)
|
||||
|
||||
mock_secret_manager_client.assert_called_once()
|
||||
call_kwargs = mock_secret_manager_client.call_args.kwargs
|
||||
assert isinstance(call_kwargs["credentials"], Credentials)
|
||||
assert call_kwargs["credentials"].token == auth_token
|
||||
assert call_kwargs["client_options"] is None
|
||||
assert call_kwargs["client_info"].user_agent == USER_AGENT
|
||||
assert isinstance(client._credentials, Credentials)
|
||||
assert client._credentials.token == auth_token
|
||||
assert client._client == mock_secret_manager_client.return_value
|
||||
|
||||
@patch("google.cloud.secretmanager.SecretManagerServiceClient")
|
||||
@patch(
|
||||
"google.adk.integrations.secret_manager.secret_client.default_service_credential"
|
||||
)
|
||||
@patch(
|
||||
"google.adk.integrations.secret_manager.secret_client._mtls_utils.get_api_endpoint"
|
||||
)
|
||||
def test_init_with_location(
|
||||
self,
|
||||
mock_get_api_endpoint,
|
||||
mock_default_service_credential,
|
||||
mock_secret_manager_client,
|
||||
):
|
||||
"""Test initialization with a specific location."""
|
||||
# Setup
|
||||
mock_credentials = MagicMock()
|
||||
mock_default_service_credential.return_value = (
|
||||
mock_credentials,
|
||||
"test-project",
|
||||
)
|
||||
location = "us-central1"
|
||||
mock_get_api_endpoint.return_value = "resolved-endpoint"
|
||||
|
||||
# Execute
|
||||
SecretManagerClient(location=location)
|
||||
|
||||
# Verify
|
||||
mock_secret_manager_client.assert_called_once()
|
||||
call_kwargs = mock_secret_manager_client.call_args.kwargs
|
||||
assert call_kwargs["credentials"] == mock_credentials
|
||||
assert call_kwargs["client_options"] == {
|
||||
"api_endpoint": "resolved-endpoint"
|
||||
}
|
||||
assert call_kwargs["client_info"].user_agent == USER_AGENT
|
||||
mock_get_api_endpoint.assert_called_once_with(
|
||||
location,
|
||||
"secretmanager.{location}.rep.googleapis.com",
|
||||
"secretmanager.{location}.rep.mtls.googleapis.com",
|
||||
)
|
||||
|
||||
@patch(
|
||||
"google.adk.integrations.secret_manager.secret_client.default_service_credential"
|
||||
)
|
||||
def test_init_with_default_credentials_error(
|
||||
self, mock_default_service_credential
|
||||
):
|
||||
"""Test initialization with default credentials that fails."""
|
||||
# Setup
|
||||
mock_default_service_credential.side_effect = Exception("Auth error")
|
||||
|
||||
# Execute and verify
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="error occurred while trying to use default credentials",
|
||||
):
|
||||
SecretManagerClient()
|
||||
|
||||
def test_init_with_invalid_service_account_json(self):
|
||||
"""Test initialization with invalid service account JSON."""
|
||||
# Execute and verify
|
||||
with pytest.raises(ValueError, match="Invalid service account JSON"):
|
||||
SecretManagerClient(service_account_json="invalid-json")
|
||||
|
||||
def test_init_with_both_service_account_json_and_auth_token(self):
|
||||
"""Test initialization rejects conflicting credential inputs."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Must provide either 'service_account_json' or 'auth_token', not"
|
||||
" both."
|
||||
),
|
||||
):
|
||||
SecretManagerClient(
|
||||
service_account_json=json.dumps({"type": "service_account"}),
|
||||
auth_token="test-token",
|
||||
)
|
||||
|
||||
@patch("google.cloud.secretmanager.SecretManagerServiceClient")
|
||||
@patch(
|
||||
"google.adk.integrations.secret_manager.secret_client.default_service_credential"
|
||||
)
|
||||
def test_get_secret(
|
||||
self, mock_default_service_credential, mock_secret_manager_client
|
||||
):
|
||||
"""Test getting a secret."""
|
||||
# Setup
|
||||
mock_credentials = MagicMock()
|
||||
mock_default_service_credential.return_value = (
|
||||
mock_credentials,
|
||||
"test-project",
|
||||
)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_secret_manager_client.return_value = mock_client
|
||||
mock_response = MagicMock()
|
||||
mock_response.payload.data.decode.return_value = "secret-value"
|
||||
mock_client.access_secret_version.return_value = mock_response
|
||||
|
||||
# Execute - use default credentials instead of auth_token
|
||||
client = SecretManagerClient()
|
||||
result = client.get_secret(
|
||||
"projects/test-project/secrets/test-secret/versions/latest"
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result == "secret-value"
|
||||
mock_client.access_secret_version.assert_called_once_with(
|
||||
name="projects/test-project/secrets/test-secret/versions/latest"
|
||||
)
|
||||
mock_response.payload.data.decode.assert_called_once_with("UTF-8")
|
||||
|
||||
@patch("google.cloud.secretmanager.SecretManagerServiceClient")
|
||||
@patch(
|
||||
"google.adk.integrations.secret_manager.secret_client.default_service_credential"
|
||||
)
|
||||
def test_get_secret_error(
|
||||
self, mock_default_service_credential, mock_secret_manager_client
|
||||
):
|
||||
"""Test getting a secret that fails."""
|
||||
# Setup
|
||||
mock_credentials = MagicMock()
|
||||
mock_default_service_credential.return_value = (
|
||||
mock_credentials,
|
||||
"test-project",
|
||||
)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_secret_manager_client.return_value = mock_client
|
||||
mock_client.access_secret_version.side_effect = Exception("Secret error")
|
||||
|
||||
# Execute and verify - use default credentials instead of auth_token
|
||||
client = SecretManagerClient()
|
||||
with pytest.raises(Exception, match="Secret error"):
|
||||
client.get_secret(
|
||||
"projects/test-project/secrets/test-secret/versions/latest"
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for GCP Skill Registry."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from unittest import mock
|
||||
import zipfile
|
||||
|
||||
from google.adk.integrations.skill_registry.gcp_skill_registry import GCPSkillRegistry
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_env():
|
||||
"""Fixture to mock environment variables."""
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GOOGLE_CLOUD_PROJECT": "test-project",
|
||||
"GOOGLE_CLOUD_LOCATION": "us-central1",
|
||||
},
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_vertex_client():
|
||||
"""Fixture to mock vertexai.Client."""
|
||||
with mock.patch(
|
||||
"google.adk.dependencies.vertexai.vertexai.Client"
|
||||
) as mock_client_class:
|
||||
mock_client = mock_client_class.return_value
|
||||
yield mock_client
|
||||
|
||||
|
||||
def _create_fake_zip_bytes():
|
||||
"""Creates a fake zip file in memory and returns its bytes."""
|
||||
import io
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w") as z:
|
||||
z.writestr(
|
||||
"SKILL.md", "---\nname: my-skill\ndescription: test\n---\n# My Skill\n"
|
||||
)
|
||||
return zip_buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_skill_success(mock_vertex_client):
|
||||
"""Verifies that get_skill successfully fetches and loads a skill in memory."""
|
||||
registry = GCPSkillRegistry()
|
||||
|
||||
fake_zip = _create_fake_zip_bytes()
|
||||
fake_zip_base64 = base64.b64encode(fake_zip).decode("utf-8")
|
||||
|
||||
mock_skill_resource = mock.MagicMock()
|
||||
mock_skill_resource.zipped_filesystem = fake_zip_base64
|
||||
|
||||
mock_vertex_client.aio.skills.get = mock.AsyncMock(
|
||||
return_value=mock_skill_resource
|
||||
)
|
||||
|
||||
skill = await registry.get_skill(name="my-skill")
|
||||
|
||||
assert skill.frontmatter.name == "my-skill"
|
||||
assert skill.frontmatter.description == "test"
|
||||
assert skill.instructions == "# My Skill"
|
||||
mock_vertex_client.aio.skills.get.assert_called_once_with(
|
||||
name="projects/test-project/locations/us-central1/skills/my-skill"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_skills_success(mock_vertex_client):
|
||||
"""Verifies that search_skills successfully returns frontmatter list."""
|
||||
registry = GCPSkillRegistry()
|
||||
|
||||
mock_skill1 = mock.MagicMock()
|
||||
mock_skill1.skill_name = (
|
||||
"projects/test-project/locations/us-central1/skills/skill1"
|
||||
)
|
||||
mock_skill1.description = "Description 1"
|
||||
|
||||
mock_skill2 = mock.MagicMock()
|
||||
mock_skill2.skill_name = (
|
||||
"projects/test-project/locations/us-central1/skills/skill2"
|
||||
)
|
||||
mock_skill2.description = "Description 2"
|
||||
|
||||
mock_response = mock.MagicMock()
|
||||
mock_response.retrieved_skills = [mock_skill1, mock_skill2]
|
||||
|
||||
mock_vertex_client.aio.skills.retrieve = mock.AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
results = await registry.search_skills(query="query")
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].name == "skill1"
|
||||
assert results[0].description == "Description 1"
|
||||
assert results[1].name == "skill2"
|
||||
assert results[1].description == "Description 2"
|
||||
mock_vertex_client.aio.skills.retrieve.assert_called_once_with(query="query")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_skill_raises_on_missing_zip(mock_vertex_client):
|
||||
"""Verifies that get_skill raises error if zip filesystem is missing."""
|
||||
registry = GCPSkillRegistry()
|
||||
|
||||
mock_skill_resource = mock.MagicMock()
|
||||
mock_skill_resource.zipped_filesystem = None
|
||||
|
||||
mock_vertex_client.aio.skills.get = mock.AsyncMock(
|
||||
return_value=mock_skill_resource
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not contain zipped filesystem"):
|
||||
await registry.get_skill(name="my-skill")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_skill_raises_on_zip_slip(mock_vertex_client):
|
||||
"""Verifies that get_skill raises error if zip contains dangerous paths."""
|
||||
registry = GCPSkillRegistry()
|
||||
|
||||
import io
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w") as z:
|
||||
z.writestr("../evil.txt", "malicious content")
|
||||
z.writestr(
|
||||
"SKILL.md", "---\nname: my-skill\ndescription: test\n---\n# My Skill\n"
|
||||
)
|
||||
fake_zip = zip_buffer.getvalue()
|
||||
fake_zip_base64 = base64.b64encode(fake_zip).decode("utf-8")
|
||||
|
||||
mock_skill_resource = mock.MagicMock()
|
||||
mock_skill_resource.zipped_filesystem = fake_zip_base64
|
||||
|
||||
mock_vertex_client.aio.skills.get = mock.AsyncMock(
|
||||
return_value=mock_skill_resource
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Dangerous zip entry ignored"):
|
||||
await registry.get_skill(name="my-skill")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_skill_raises_on_invalid_skill_name(mock_vertex_client):
|
||||
"""Verifies that get_skill raises error if skill name is invalid."""
|
||||
registry = GCPSkillRegistry()
|
||||
|
||||
import io
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w") as z:
|
||||
z.writestr(
|
||||
"SKILL.md", "---\nname: ../evil\ndescription: test\n---\n# My Skill\n"
|
||||
)
|
||||
fake_zip = zip_buffer.getvalue()
|
||||
fake_zip_base64 = base64.b64encode(fake_zip).decode("utf-8")
|
||||
|
||||
mock_skill_resource = mock.MagicMock()
|
||||
mock_skill_resource.zipped_filesystem = fake_zip_base64
|
||||
|
||||
mock_vertex_client.aio.skills.get = mock.AsyncMock(
|
||||
return_value=mock_skill_resource
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid skill name in SKILL.md"):
|
||||
await registry.get_skill(name="my-skill")
|
||||
@@ -0,0 +1,152 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("slack_bolt")
|
||||
|
||||
from google.adk.integrations.slack import SlackRunner
|
||||
from google.adk.runners import Runner
|
||||
from google.genai import types
|
||||
from slack_bolt.app.async_app import AsyncApp
|
||||
|
||||
|
||||
class TestSlackRunner(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.mock_runner = MagicMock(spec=Runner)
|
||||
self.mock_slack_app = MagicMock(spec=AsyncApp)
|
||||
self.mock_slack_app.client = MagicMock()
|
||||
self.mock_slack_app.client.chat_update = AsyncMock()
|
||||
self.mock_slack_app.client.chat_delete = AsyncMock()
|
||||
self.slack_runner = SlackRunner(self.mock_runner, self.mock_slack_app)
|
||||
|
||||
@patch("google.adk.integrations.slack.slack_runner.logger")
|
||||
async def test_handle_message_success(self, mock_logger):
|
||||
# Setup mocks
|
||||
mock_say = AsyncMock()
|
||||
mock_say.return_value = {"ts": "thinking_ts"}
|
||||
event = {
|
||||
"text": "Hello bot",
|
||||
"user": "U12345",
|
||||
"channel": "C67890",
|
||||
"ts": "1234567890.123456",
|
||||
}
|
||||
|
||||
# Mock runner.run_async to yield a response
|
||||
mock_event = MagicMock()
|
||||
mock_event.content = types.Content(
|
||||
role="model", parts=[types.Part(text="Hi user!")]
|
||||
)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
yield mock_event
|
||||
|
||||
self.mock_runner.run_async.side_effect = mock_run_async
|
||||
|
||||
# Call the handler
|
||||
await self.slack_runner._handle_message(event, mock_say)
|
||||
|
||||
# Verify calls
|
||||
self.mock_runner.run_async.assert_called_once()
|
||||
mock_say.assert_called_once_with(
|
||||
text="_Thinking..._", thread_ts="1234567890.123456"
|
||||
)
|
||||
self.mock_slack_app.client.chat_update.assert_called_once_with(
|
||||
channel="C67890",
|
||||
ts="thinking_ts",
|
||||
text="Hi user!",
|
||||
)
|
||||
|
||||
@patch("google.adk.integrations.slack.slack_runner.logger")
|
||||
async def test_handle_message_multi_turn(self, mock_logger):
|
||||
# Setup mocks
|
||||
mock_say = AsyncMock()
|
||||
mock_say.return_value = {"ts": "thinking_ts"}
|
||||
event = {
|
||||
"text": "Tell me two things",
|
||||
"user": "U12345",
|
||||
"channel": "C67890",
|
||||
"ts": "1234567890.123456",
|
||||
}
|
||||
|
||||
# Mock runner.run_async to yield two responses
|
||||
e1 = MagicMock()
|
||||
e1.content = types.Content(
|
||||
role="model", parts=[types.Part(text="First thing.")]
|
||||
)
|
||||
e2 = MagicMock()
|
||||
e2.content = types.Content(
|
||||
role="model", parts=[types.Part(text="Second thing.")]
|
||||
)
|
||||
|
||||
async def mock_run_async(*args, **kwargs):
|
||||
yield e1
|
||||
yield e2
|
||||
|
||||
self.mock_runner.run_async.side_effect = mock_run_async
|
||||
|
||||
await self.slack_runner._handle_message(event, mock_say)
|
||||
|
||||
# First message uses chat_update
|
||||
self.mock_slack_app.client.chat_update.assert_called_once_with(
|
||||
channel="C67890",
|
||||
ts="thinking_ts",
|
||||
text="First thing.",
|
||||
)
|
||||
# Second message uses say
|
||||
self.assertEqual(mock_say.call_count, 2)
|
||||
mock_say.assert_any_call(
|
||||
text="_Thinking..._", thread_ts="1234567890.123456"
|
||||
)
|
||||
mock_say.assert_any_call(
|
||||
text="Second thing.", thread_ts="1234567890.123456"
|
||||
)
|
||||
|
||||
@patch("google.adk.integrations.slack.slack_runner.logger")
|
||||
async def test_handle_message_error(self, mock_logger):
|
||||
mock_say = AsyncMock()
|
||||
mock_say.return_value = {"ts": "thinking_ts"}
|
||||
event = {
|
||||
"text": "Trigger error",
|
||||
"user": "U12345",
|
||||
"channel": "C67890",
|
||||
"ts": "1234567890.123456",
|
||||
}
|
||||
|
||||
async def mock_run_async_error(*args, **kwargs):
|
||||
raise Exception("Something went wrong")
|
||||
yield # To make it a generator
|
||||
|
||||
self.mock_runner.run_async.side_effect = mock_run_async_error
|
||||
|
||||
await self.slack_runner._handle_message(event, mock_say)
|
||||
|
||||
mock_say.assert_called_once_with(
|
||||
text="_Thinking..._", thread_ts="1234567890.123456"
|
||||
)
|
||||
self.mock_slack_app.client.chat_update.assert_called_once()
|
||||
self.assertIn(
|
||||
"Sorry, I encountered an error",
|
||||
self.mock_slack_app.client.chat_update.call_args[1]["text"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,364 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for the SandboxClient class."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.integrations.vmaas.sandbox_client import SandboxClient
|
||||
|
||||
|
||||
def _make_response(data: dict) -> MagicMock:
|
||||
"""Create a mock HttpResponse with a JSON body."""
|
||||
response = MagicMock()
|
||||
response.body = json.dumps(data)
|
||||
return response
|
||||
|
||||
|
||||
class TestSandboxClient(unittest.IsolatedAsyncioTestCase):
|
||||
"""Tests for SandboxClient."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.mock_vertexai_client = MagicMock()
|
||||
self.mock_sandbox = MagicMock()
|
||||
self.access_token = "test_token_12345"
|
||||
self.client = SandboxClient(
|
||||
vertexai_client=self.mock_vertexai_client,
|
||||
sandbox=self.mock_sandbox,
|
||||
access_token=self.access_token,
|
||||
)
|
||||
|
||||
def test_init(self):
|
||||
"""Test client initialization."""
|
||||
self.assertEqual(self.client._client, self.mock_vertexai_client)
|
||||
self.assertEqual(self.client._sandbox, self.mock_sandbox)
|
||||
self.assertEqual(self.client._access_token, self.access_token)
|
||||
|
||||
def test_update_access_token(self):
|
||||
"""Test updating access token."""
|
||||
new_token = "new_token_67890"
|
||||
self.client.update_access_token(new_token)
|
||||
self.assertEqual(self.client._access_token, new_token)
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_make_cdp_request(self, mock_to_thread):
|
||||
"""Test making a single CDP request."""
|
||||
mock_to_thread.return_value = _make_response({"result": "success"})
|
||||
|
||||
result = await self.client.make_cdp_request(
|
||||
"Page.navigate", {"url": "https://example.com"}
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"result": "success"})
|
||||
mock_to_thread.assert_called_once()
|
||||
call_args = mock_to_thread.call_args
|
||||
# First positional arg is the send_command method
|
||||
self.assertEqual(
|
||||
call_args[0][0],
|
||||
self.mock_vertexai_client.agent_engines.sandboxes.send_command,
|
||||
)
|
||||
# Check keyword args
|
||||
self.assertEqual(call_args[1]["http_method"], "POST")
|
||||
self.assertEqual(call_args[1]["path"], "cdp")
|
||||
self.assertEqual(call_args[1]["access_token"], self.access_token)
|
||||
self.assertEqual(call_args[1]["sandbox_environment"], self.mock_sandbox)
|
||||
self.assertEqual(
|
||||
call_args[1]["request_dict"],
|
||||
{"command": "Page.navigate", "params": {"url": "https://example.com"}},
|
||||
)
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_make_cdp_batch_request_with_batch_endpoint(
|
||||
self, mock_to_thread
|
||||
):
|
||||
"""Test making a batch CDP request using batch endpoint."""
|
||||
mock_to_thread.return_value = _make_response(
|
||||
{"results": [{"status": "success"}, {"status": "success"}]}
|
||||
)
|
||||
|
||||
commands = [
|
||||
{
|
||||
"command": "Input.dispatchMouseEvent",
|
||||
"params": {"type": "mousePressed"},
|
||||
},
|
||||
{
|
||||
"command": "Input.dispatchMouseEvent",
|
||||
"params": {"type": "mouseReleased"},
|
||||
},
|
||||
]
|
||||
result = await self.client.make_cdp_batch_request(commands)
|
||||
|
||||
# Should have 2 results from batch
|
||||
self.assertEqual(len(result), 2)
|
||||
# Should have made 1 call to /cdps
|
||||
self.assertEqual(mock_to_thread.call_count, 1)
|
||||
call_args = mock_to_thread.call_args
|
||||
self.assertEqual(call_args[1]["path"], "cdps")
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_make_cdp_batch_request_fallback_sequential(
|
||||
self, mock_to_thread
|
||||
):
|
||||
"""Test batch CDP falls back to sequential when batch endpoint fails."""
|
||||
|
||||
# First call (to /cdps) fails with 404
|
||||
# Subsequent calls (to /cdp) succeed
|
||||
def side_effect(*args, **kwargs):
|
||||
if kwargs.get("path") == "cdps":
|
||||
raise Exception("404 Not Found")
|
||||
return _make_response({"result": "ok"})
|
||||
|
||||
mock_to_thread.side_effect = side_effect
|
||||
|
||||
commands = [
|
||||
{"command": "Command1", "params": {}},
|
||||
{"command": "Command2", "params": {}},
|
||||
]
|
||||
result = await self.client.make_cdp_batch_request(commands)
|
||||
|
||||
# Should have 2 results (sequential fallback)
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertEqual(result[0]["status"], "success")
|
||||
self.assertEqual(result[1]["status"], "success")
|
||||
# Should have made 3 calls (1 failed /cdps + 2 sequential /cdp)
|
||||
self.assertEqual(mock_to_thread.call_count, 3)
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_get_screenshot(self, mock_to_thread):
|
||||
"""Test capturing a screenshot."""
|
||||
# Create a simple PNG-like base64 data
|
||||
png_data = b"\x89PNG\r\n\x1a\n"
|
||||
base64_data = base64.b64encode(png_data).decode()
|
||||
|
||||
mock_to_thread.return_value = _make_response({"data": base64_data})
|
||||
|
||||
result = await self.client.get_screenshot()
|
||||
|
||||
self.assertEqual(result, png_data)
|
||||
call_args = mock_to_thread.call_args
|
||||
self.assertEqual(
|
||||
call_args[1]["request_dict"]["command"], "Page.captureScreenshot"
|
||||
)
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_get_current_url(self, mock_to_thread):
|
||||
"""Test getting the current URL."""
|
||||
mock_to_thread.return_value = _make_response({
|
||||
"active_tab_id": "tab1",
|
||||
"all_tabs": [
|
||||
{"id": "tab1", "url": "https://example.com", "title": "Example"},
|
||||
],
|
||||
})
|
||||
|
||||
result = await self.client.get_current_url()
|
||||
|
||||
self.assertEqual(result, "https://example.com")
|
||||
call_args = mock_to_thread.call_args
|
||||
self.assertEqual(call_args[1]["path"], "tabs")
|
||||
self.assertEqual(call_args[1]["http_method"], "GET")
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_get_current_url_no_active_tab(self, mock_to_thread):
|
||||
"""Test getting URL when no tab is active."""
|
||||
mock_to_thread.return_value = _make_response({
|
||||
"active_tab_id": None,
|
||||
"all_tabs": [],
|
||||
})
|
||||
|
||||
result = await self.client.get_current_url()
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_navigate(self, mock_to_thread):
|
||||
"""Test navigating to a URL."""
|
||||
mock_to_thread.return_value = _make_response({"frameId": "frame123"})
|
||||
|
||||
result = await self.client.navigate("https://example.com")
|
||||
|
||||
self.assertEqual(result, {"frameId": "frame123"})
|
||||
call_args = mock_to_thread.call_args
|
||||
self.assertEqual(
|
||||
call_args[1]["request_dict"],
|
||||
{"command": "Page.navigate", "params": {"url": "https://example.com"}},
|
||||
)
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_click_at(self, mock_to_thread):
|
||||
"""Test clicking at coordinates."""
|
||||
# Return success for batch endpoint
|
||||
mock_to_thread.return_value = _make_response({"results": [{}, {}]})
|
||||
|
||||
await self.client.click_at(100, 200)
|
||||
|
||||
# Should call batch endpoint
|
||||
call_args = mock_to_thread.call_args
|
||||
self.assertEqual(call_args[1]["path"], "cdps")
|
||||
commands = call_args[1]["request_dict"]["commands"]
|
||||
self.assertEqual(len(commands), 2)
|
||||
self.assertEqual(commands[0]["params"]["type"], "mousePressed")
|
||||
self.assertEqual(commands[1]["params"]["type"], "mouseReleased")
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_hover_at(self, mock_to_thread):
|
||||
"""Test hovering at coordinates."""
|
||||
mock_to_thread.return_value = _make_response({})
|
||||
|
||||
await self.client.hover_at(150, 250)
|
||||
|
||||
call_args = mock_to_thread.call_args
|
||||
self.assertEqual(
|
||||
call_args[1]["request_dict"]["params"],
|
||||
{"type": "mouseMoved", "x": 150, "y": 250},
|
||||
)
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_scroll_at_down(self, mock_to_thread):
|
||||
"""Test scrolling down."""
|
||||
mock_to_thread.return_value = _make_response({})
|
||||
|
||||
await self.client.scroll_at(100, 200, "down", 300)
|
||||
|
||||
call_args = mock_to_thread.call_args
|
||||
params = call_args[1]["request_dict"]["params"]
|
||||
self.assertEqual(params["type"], "mouseWheel")
|
||||
self.assertEqual(params["x"], 100)
|
||||
self.assertEqual(params["y"], 200)
|
||||
self.assertEqual(params["deltaX"], 0)
|
||||
self.assertEqual(params["deltaY"], 300) # Positive for down
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_scroll_at_up(self, mock_to_thread):
|
||||
"""Test scrolling up."""
|
||||
mock_to_thread.return_value = _make_response({})
|
||||
|
||||
await self.client.scroll_at(100, 200, "up", 300)
|
||||
|
||||
call_args = mock_to_thread.call_args
|
||||
params = call_args[1]["request_dict"]["params"]
|
||||
self.assertEqual(params["deltaY"], -300) # Negative for up
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_go_back(self, mock_to_thread):
|
||||
"""Test navigating back."""
|
||||
# First call returns navigation history, second navigates
|
||||
mock_to_thread.side_effect = [
|
||||
_make_response({
|
||||
"currentIndex": 1,
|
||||
"entries": [
|
||||
{"id": 1, "url": "https://first.com"},
|
||||
{"id": 2, "url": "https://second.com"},
|
||||
],
|
||||
}),
|
||||
_make_response({}), # Navigation response
|
||||
]
|
||||
|
||||
result = await self.client.go_back()
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(mock_to_thread.call_count, 2)
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_go_back_at_beginning(self, mock_to_thread):
|
||||
"""Test navigating back when at beginning of history."""
|
||||
mock_to_thread.return_value = _make_response({
|
||||
"currentIndex": 0,
|
||||
"entries": [{"id": 1, "url": "https://first.com"}],
|
||||
})
|
||||
|
||||
result = await self.client.go_back()
|
||||
|
||||
self.assertFalse(result)
|
||||
# Should only call once (to get history)
|
||||
self.assertEqual(mock_to_thread.call_count, 1)
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_type_text_with_clear_and_enter(self, mock_to_thread):
|
||||
"""Test typing text with clear and enter options."""
|
||||
# Return success for batch endpoint
|
||||
mock_to_thread.return_value = _make_response({"results": [{}] * 7})
|
||||
|
||||
await self.client.type_text(
|
||||
"hello", press_enter=True, clear_before_typing=True
|
||||
)
|
||||
|
||||
# Should have: Ctrl+A down, Ctrl+A up, Delete down, Delete up,
|
||||
# insertText, Enter down, Enter up = 7 commands in batch
|
||||
call_args = mock_to_thread.call_args
|
||||
commands = call_args[1]["request_dict"]["commands"]
|
||||
self.assertEqual(len(commands), 7)
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_key_combination(self, mock_to_thread):
|
||||
"""Test pressing key combinations."""
|
||||
mock_to_thread.return_value = _make_response({"results": [{}] * 4})
|
||||
|
||||
await self.client.key_combination(["control", "c"])
|
||||
|
||||
# Should have: Control down, c down, c up, Control up = 4 commands
|
||||
call_args = mock_to_thread.call_args
|
||||
commands = call_args[1]["request_dict"]["commands"]
|
||||
self.assertEqual(len(commands), 4)
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_drag_and_drop(self, mock_to_thread):
|
||||
"""Test drag and drop operation."""
|
||||
mock_to_thread.return_value = _make_response({"results": [{}] * 4})
|
||||
|
||||
await self.client.drag_and_drop(10, 20, 100, 200)
|
||||
|
||||
# Should have: mouseMoved (start), mousePressed, mouseMoved (end),
|
||||
# mouseReleased = 4 commands
|
||||
call_args = mock_to_thread.call_args
|
||||
commands = call_args[1]["request_dict"]["commands"]
|
||||
self.assertEqual(len(commands), 4)
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_health_check_healthy(self, mock_to_thread):
|
||||
"""Test health check when sandbox is healthy."""
|
||||
mock_to_thread.return_value = _make_response({"status": "healthy"})
|
||||
|
||||
result = await self.client.health_check()
|
||||
|
||||
self.assertTrue(result)
|
||||
call_args = mock_to_thread.call_args
|
||||
self.assertEqual(call_args[1]["http_method"], "GET")
|
||||
self.assertEqual(call_args[1]["path"], "")
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_health_check_unhealthy(self, mock_to_thread):
|
||||
"""Test health check when sandbox is unhealthy."""
|
||||
mock_to_thread.return_value = _make_response({"status": "unhealthy"})
|
||||
|
||||
result = await self.client.health_check()
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
@patch("asyncio.to_thread")
|
||||
async def test_health_check_exception(self, mock_to_thread):
|
||||
"""Test health check when request fails."""
|
||||
mock_to_thread.side_effect = Exception("Connection failed")
|
||||
|
||||
result = await self.client.health_check()
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,576 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for the AgentEngineSandboxComputer class."""
|
||||
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.integrations.vmaas.sandbox_computer import _STATE_KEY_ACCESS_TOKEN
|
||||
from google.adk.integrations.vmaas.sandbox_computer import _STATE_KEY_AGENT_ENGINE_NAME
|
||||
from google.adk.integrations.vmaas.sandbox_computer import _STATE_KEY_SANDBOX_NAME
|
||||
from google.adk.integrations.vmaas.sandbox_computer import _STATE_KEY_TOKEN_EXPIRY
|
||||
from google.adk.integrations.vmaas.sandbox_computer import AgentEngineSandboxComputer
|
||||
from google.adk.tools.computer_use.base_computer import ComputerEnvironment
|
||||
from google.adk.tools.computer_use.base_computer import ComputerState
|
||||
|
||||
|
||||
class TestAgentEngineSandboxComputer(unittest.IsolatedAsyncioTestCase):
|
||||
"""Tests for AgentEngineSandboxComputer."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.project_id = "test-project"
|
||||
self.location = "us-central1"
|
||||
self.service_account = "sa@test-project.iam.gserviceaccount.com"
|
||||
|
||||
def test_init(self):
|
||||
"""Test computer initialization."""
|
||||
computer = AgentEngineSandboxComputer(
|
||||
project_id=self.project_id,
|
||||
location=self.location,
|
||||
service_account_email=self.service_account,
|
||||
)
|
||||
|
||||
self.assertEqual(computer._project_id, self.project_id)
|
||||
self.assertEqual(computer._location, self.location)
|
||||
self.assertEqual(computer._service_account_email, self.service_account)
|
||||
self.assertEqual(computer._screen_size, (1280, 720))
|
||||
|
||||
def test_init_with_byos(self):
|
||||
"""Test initialization with bring-your-own-sandbox."""
|
||||
agent_engine_name = (
|
||||
"projects/test/locations/us-central1/reasoningEngines/123"
|
||||
)
|
||||
sandbox_name = f"{agent_engine_name}/sandboxEnvironments/456"
|
||||
|
||||
computer = AgentEngineSandboxComputer(
|
||||
project_id=self.project_id,
|
||||
sandbox_name=sandbox_name,
|
||||
)
|
||||
|
||||
# Agent engine name should be extracted from sandbox_name
|
||||
self.assertEqual(computer._agent_engine_name, agent_engine_name)
|
||||
self.assertEqual(computer._sandbox_name, sandbox_name)
|
||||
|
||||
def test_init_with_template_derives_agent_engine(self):
|
||||
"""Test agent engine is derived from sandbox_template_name."""
|
||||
agent_engine_name = (
|
||||
"projects/test/locations/us-central1/reasoningEngines/123"
|
||||
)
|
||||
template_name = f"{agent_engine_name}/sandboxEnvironmentTemplates/789"
|
||||
|
||||
computer = AgentEngineSandboxComputer(
|
||||
project_id=self.project_id,
|
||||
sandbox_template_name=template_name,
|
||||
)
|
||||
|
||||
# Agent engine name should be derived from the template name so the
|
||||
# sandbox is created under the template's reasoning engine (no BYOS).
|
||||
self.assertEqual(computer._agent_engine_name, agent_engine_name)
|
||||
self.assertEqual(computer._sandbox_template_name, template_name)
|
||||
|
||||
def test_init_with_snapshot_derives_agent_engine(self):
|
||||
"""Test agent engine is derived from sandbox_snapshot_name."""
|
||||
agent_engine_name = (
|
||||
"projects/test/locations/us-central1/reasoningEngines/123"
|
||||
)
|
||||
snapshot_name = f"{agent_engine_name}/sandboxEnvironmentSnapshots/789"
|
||||
|
||||
computer = AgentEngineSandboxComputer(
|
||||
project_id=self.project_id,
|
||||
sandbox_snapshot_name=snapshot_name,
|
||||
)
|
||||
|
||||
self.assertEqual(computer._agent_engine_name, agent_engine_name)
|
||||
self.assertEqual(computer._sandbox_snapshot_name, snapshot_name)
|
||||
|
||||
def test_init_sandbox_name_takes_precedence_over_template(self):
|
||||
"""Test sandbox_name wins when both sandbox_name and template are set."""
|
||||
sandbox_engine = (
|
||||
"projects/test/locations/us-central1/reasoningEngines/sandbox"
|
||||
)
|
||||
sandbox_name = f"{sandbox_engine}/sandboxEnvironments/456"
|
||||
template_name = (
|
||||
"projects/test/locations/us-central1/reasoningEngines/template"
|
||||
"/sandboxEnvironmentTemplates/789"
|
||||
)
|
||||
|
||||
computer = AgentEngineSandboxComputer(
|
||||
project_id=self.project_id,
|
||||
sandbox_name=sandbox_name,
|
||||
sandbox_template_name=template_name,
|
||||
)
|
||||
|
||||
self.assertEqual(computer._agent_engine_name, sandbox_engine)
|
||||
|
||||
def test_init_without_sandbox_source_has_no_agent_engine(self):
|
||||
"""Test agent engine is None when no sandbox source is provided."""
|
||||
computer = AgentEngineSandboxComputer(project_id=self.project_id)
|
||||
self.assertIsNone(computer._agent_engine_name)
|
||||
|
||||
async def test_ensure_agent_engine_with_template_name(self):
|
||||
"""Test _ensure_agent_engine reuses the template's reasoning engine."""
|
||||
agent_engine_name = (
|
||||
"projects/test/locations/us-central1/reasoningEngines/123"
|
||||
)
|
||||
template_name = f"{agent_engine_name}/sandboxEnvironmentTemplates/789"
|
||||
computer = AgentEngineSandboxComputer(sandbox_template_name=template_name)
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer._ensure_agent_engine()
|
||||
|
||||
self.assertEqual(result, agent_engine_name)
|
||||
# Should not have created or stored a new engine.
|
||||
self.assertNotIn(_STATE_KEY_AGENT_ENGINE_NAME, computer._session_state)
|
||||
|
||||
def test_init_with_vertexai_client(self):
|
||||
"""Test initialization with provided vertexai client."""
|
||||
mock_client = MagicMock()
|
||||
computer = AgentEngineSandboxComputer(vertexai_client=mock_client)
|
||||
self.assertEqual(computer._client, mock_client)
|
||||
|
||||
async def test_screen_size(self):
|
||||
"""Test screen_size returns hardcoded size."""
|
||||
computer = AgentEngineSandboxComputer()
|
||||
result = await computer.screen_size()
|
||||
self.assertEqual(result, (1280, 720))
|
||||
|
||||
async def test_environment(self):
|
||||
"""Test environment returns ENVIRONMENT_BROWSER."""
|
||||
computer = AgentEngineSandboxComputer()
|
||||
result = await computer.environment()
|
||||
self.assertEqual(result, ComputerEnvironment.ENVIRONMENT_BROWSER)
|
||||
|
||||
async def test_ensure_agent_engine_with_sandbox_name(self):
|
||||
"""Test _ensure_agent_engine extracts agent engine from sandbox_name."""
|
||||
agent_engine_name = (
|
||||
"projects/test/locations/us-central1/reasoningEngines/123"
|
||||
)
|
||||
sandbox_name = f"{agent_engine_name}/sandboxEnvironments/456"
|
||||
computer = AgentEngineSandboxComputer(sandbox_name=sandbox_name)
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer._ensure_agent_engine()
|
||||
|
||||
self.assertEqual(result, agent_engine_name)
|
||||
# Should not have touched session state
|
||||
self.assertNotIn(_STATE_KEY_AGENT_ENGINE_NAME, computer._session_state)
|
||||
|
||||
async def test_ensure_agent_engine_from_session_state(self):
|
||||
"""Test _ensure_agent_engine uses session state value."""
|
||||
agent_engine_name = (
|
||||
"projects/test/locations/us-central1/reasoningEngines/123"
|
||||
)
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {_STATE_KEY_AGENT_ENGINE_NAME: agent_engine_name}
|
||||
|
||||
result = await computer._ensure_agent_engine()
|
||||
|
||||
self.assertEqual(result, agent_engine_name)
|
||||
|
||||
@patch("google.adk.integrations.vmaas.sandbox_computer.asyncio.to_thread")
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_client")
|
||||
async def test_ensure_agent_engine_creates_new(
|
||||
self, mock_get_client, mock_to_thread
|
||||
):
|
||||
"""Test _ensure_agent_engine creates new agent engine."""
|
||||
new_engine_name = "projects/test/locations/us-central1/reasoningEngines/new"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.api_resource.name = new_engine_name
|
||||
mock_to_thread.return_value = mock_engine
|
||||
|
||||
computer = AgentEngineSandboxComputer(project_id=self.project_id)
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer._ensure_agent_engine()
|
||||
|
||||
self.assertEqual(result, new_engine_name)
|
||||
self.assertEqual(
|
||||
computer._session_state[_STATE_KEY_AGENT_ENGINE_NAME], new_engine_name
|
||||
)
|
||||
|
||||
@patch("google.adk.integrations.vmaas.sandbox_computer.asyncio.to_thread")
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_client")
|
||||
async def test_get_sandbox_with_constructor_value(
|
||||
self, mock_get_client, mock_to_thread
|
||||
):
|
||||
"""Test _get_sandbox uses constructor value (BYOS mode)."""
|
||||
sandbox_name = "projects/test/sandboxEnvironments/123"
|
||||
|
||||
mock_sandbox = MagicMock()
|
||||
mock_sandbox.name = sandbox_name
|
||||
mock_to_thread.return_value = mock_sandbox
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer(sandbox_name=sandbox_name)
|
||||
computer._session_state = {}
|
||||
|
||||
result_name, result_sandbox = await computer._get_sandbox()
|
||||
|
||||
self.assertEqual(result_name, sandbox_name)
|
||||
self.assertEqual(result_sandbox, mock_sandbox)
|
||||
|
||||
@patch("google.adk.integrations.vmaas.sandbox_computer.asyncio.to_thread")
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_client")
|
||||
async def test_get_sandbox_from_session_state(
|
||||
self, mock_get_client, mock_to_thread
|
||||
):
|
||||
"""Test _get_sandbox uses session state value."""
|
||||
sandbox_name = "projects/test/sandboxEnvironments/123"
|
||||
|
||||
mock_sandbox = MagicMock()
|
||||
mock_to_thread.return_value = mock_sandbox
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {_STATE_KEY_SANDBOX_NAME: sandbox_name}
|
||||
|
||||
result_name, result_sandbox = await computer._get_sandbox()
|
||||
|
||||
self.assertEqual(result_name, sandbox_name)
|
||||
self.assertEqual(result_sandbox, mock_sandbox)
|
||||
|
||||
async def test_get_access_token_cached(self):
|
||||
"""Test _get_access_token uses cached token."""
|
||||
sandbox_name = "projects/test/sandboxEnvironments/123"
|
||||
cached_token = "cached_token_123"
|
||||
# Set expiry far in the future
|
||||
token_expiry = time.time() + 3600
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {
|
||||
_STATE_KEY_ACCESS_TOKEN: cached_token,
|
||||
_STATE_KEY_TOKEN_EXPIRY: token_expiry,
|
||||
}
|
||||
|
||||
result = await computer._get_access_token(sandbox_name)
|
||||
|
||||
self.assertEqual(result, cached_token)
|
||||
|
||||
@patch("google.adk.integrations.vmaas.sandbox_computer.asyncio.to_thread")
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_client")
|
||||
async def test_get_access_token_generates_new_when_expired(
|
||||
self, mock_get_client, mock_to_thread
|
||||
):
|
||||
"""Test _get_access_token generates new token when expired."""
|
||||
sandbox_name = "projects/test/sandboxEnvironments/123"
|
||||
new_token = "new_token_456"
|
||||
# Set expiry in the past
|
||||
token_expiry = time.time() - 100
|
||||
|
||||
mock_to_thread.return_value = new_token
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer(
|
||||
service_account_email=self.service_account
|
||||
)
|
||||
computer._session_state = {
|
||||
_STATE_KEY_ACCESS_TOKEN: "old_token",
|
||||
_STATE_KEY_TOKEN_EXPIRY: token_expiry,
|
||||
}
|
||||
|
||||
result = await computer._get_access_token(sandbox_name)
|
||||
|
||||
self.assertEqual(result, new_token)
|
||||
self.assertEqual(
|
||||
computer._session_state[_STATE_KEY_ACCESS_TOKEN], new_token
|
||||
)
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_click_at(self, mock_get_client):
|
||||
"""Test click_at method."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.click_at = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(return_value="https://example.com")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer.click_at(100, 200)
|
||||
|
||||
mock_client.click_at.assert_called_once_with(100, 200)
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
self.assertEqual(result.screenshot, b"png_data")
|
||||
self.assertEqual(result.url, "https://example.com")
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_hover_at(self, mock_get_client):
|
||||
"""Test hover_at method."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.hover_at = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(return_value="https://example.com")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer.hover_at(150, 250)
|
||||
|
||||
mock_client.hover_at.assert_called_once_with(150, 250)
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_type_text_at(self, mock_get_client):
|
||||
"""Test type_text_at method."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.type_text_at = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(return_value="https://example.com")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer.type_text_at(
|
||||
100,
|
||||
200,
|
||||
"hello",
|
||||
press_enter=True,
|
||||
clear_before_typing=False,
|
||||
)
|
||||
|
||||
mock_client.type_text_at.assert_called_once_with(
|
||||
x=100,
|
||||
y=200,
|
||||
text="hello",
|
||||
press_enter=True,
|
||||
clear_before_typing=False,
|
||||
)
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_scroll_document(self, mock_get_client):
|
||||
"""Test scroll_document method."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.scroll_at = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(return_value="https://example.com")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer.scroll_document("down")
|
||||
|
||||
# Should scroll at center of screen
|
||||
mock_client.scroll_at.assert_called_once_with(640, 360, "down", 400)
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_scroll_at(self, mock_get_client):
|
||||
"""Test scroll_at method."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.scroll_at = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(return_value="https://example.com")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer.scroll_at(100, 200, "up", 500)
|
||||
|
||||
mock_client.scroll_at.assert_called_once_with(100, 200, "up", 500)
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_navigate(self, mock_get_client):
|
||||
"""Test navigate method."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.navigate = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(return_value="https://newsite.com")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer.navigate("https://newsite.com")
|
||||
|
||||
mock_client.navigate.assert_called_once_with("https://newsite.com")
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
self.assertEqual(result.url, "https://newsite.com")
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_search(self, mock_get_client):
|
||||
"""Test search method navigates to search engine."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.navigate = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(
|
||||
return_value="https://www.google.com"
|
||||
)
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer(
|
||||
search_engine_url="https://www.google.com"
|
||||
)
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer.search()
|
||||
|
||||
mock_client.navigate.assert_called_once_with("https://www.google.com")
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_go_back(self, mock_get_client):
|
||||
"""Test go_back method."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.go_back = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(return_value="https://prev.com")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer.go_back()
|
||||
|
||||
mock_client.go_back.assert_called_once()
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_go_forward(self, mock_get_client):
|
||||
"""Test go_forward method."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.go_forward = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(return_value="https://next.com")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer.go_forward()
|
||||
|
||||
mock_client.go_forward.assert_called_once()
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_key_combination(self, mock_get_client):
|
||||
"""Test key_combination method."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.key_combination = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(return_value="https://example.com")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer.key_combination(["control", "c"])
|
||||
|
||||
mock_client.key_combination.assert_called_once_with(["control", "c"])
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_drag_and_drop(self, mock_get_client):
|
||||
"""Test drag_and_drop method."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.drag_and_drop = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(return_value="https://example.com")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer.drag_and_drop(10, 20, 100, 200)
|
||||
|
||||
mock_client.drag_and_drop.assert_called_once_with(10, 20, 100, 200)
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_wait(self, mock_get_client):
|
||||
"""Test wait method."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(return_value="https://example.com")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {}
|
||||
|
||||
# Use a short wait time for testing
|
||||
start_time = time.time()
|
||||
result = await computer.wait(1)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
self.assertGreaterEqual(elapsed, 0.9) # Allow some margin
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_current_state(self, mock_get_client):
|
||||
"""Test current_state method."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(return_value="https://example.com")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer.current_state()
|
||||
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
self.assertEqual(result.screenshot, b"png_data")
|
||||
self.assertEqual(result.url, "https://example.com")
|
||||
|
||||
@patch.object(AgentEngineSandboxComputer, "_get_sandbox_client")
|
||||
async def test_open_web_browser(self, mock_get_client):
|
||||
"""Test open_web_browser method returns current state."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_screenshot = AsyncMock(return_value=b"png_data")
|
||||
mock_client.get_current_url = AsyncMock(return_value="about:blank")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
computer = AgentEngineSandboxComputer()
|
||||
computer._session_state = {}
|
||||
|
||||
result = await computer.open_web_browser()
|
||||
|
||||
# open_web_browser is a no-op for sandbox, just returns current state
|
||||
self.assertIsInstance(result, ComputerState)
|
||||
|
||||
async def test_initialize_is_noop(self):
|
||||
"""Test initialize does nothing (lazy provisioning)."""
|
||||
computer = AgentEngineSandboxComputer()
|
||||
# Should not raise
|
||||
await computer.initialize()
|
||||
|
||||
async def test_close_is_noop(self):
|
||||
"""Test close does nothing (TTL-based cleanup)."""
|
||||
computer = AgentEngineSandboxComputer()
|
||||
# Should not raise
|
||||
await computer.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user