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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
@@ -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
)
@@ -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
@@ -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()