ec2b666284
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
1109 lines
34 KiB
Python
1109 lines
34 KiB
Python
# 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.
|
|
|
|
"""Integration tests for /trigger/* endpoints.
|
|
|
|
Tests exercise the full FastAPI request → TriggerRouter → Runner pipeline
|
|
using the same TestClient pattern as test_fast_api.py, with a mocked Runner
|
|
that returns deterministic events.
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import signal
|
|
from typing import Optional
|
|
from unittest.mock import AsyncMock
|
|
from unittest.mock import MagicMock
|
|
from unittest.mock import patch
|
|
|
|
from fastapi.testclient import TestClient
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
from google.adk.agents.run_config import RunConfig
|
|
from google.adk.cli import fast_api as fast_api_module
|
|
from google.adk.cli.fast_api import get_fast_api_app
|
|
from google.adk.cli.trigger_routes import _is_transient_error
|
|
from google.adk.cli.trigger_routes import TransientError
|
|
from google.adk.cli.trigger_routes import TriggerRouter
|
|
from google.adk.events.event import Event
|
|
from google.adk.runners import Runner
|
|
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
|
from google.genai import types
|
|
import pytest
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dummy agent & mocked runner (same pattern as test_fast_api.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class DummyAgent(BaseAgent):
|
|
|
|
def __init__(self, name):
|
|
super().__init__(name=name)
|
|
self.sub_agents = []
|
|
|
|
|
|
root_agent = DummyAgent(name="trigger_test_agent")
|
|
|
|
|
|
def _model_event(text: str = "Agent reply") -> Event:
|
|
return Event(
|
|
author="trigger_test_agent",
|
|
invocation_id="inv-trigger",
|
|
content=types.Content(
|
|
role="model",
|
|
parts=[types.Part(text=text)],
|
|
),
|
|
)
|
|
|
|
|
|
async def dummy_run_async(
|
|
self,
|
|
user_id,
|
|
session_id,
|
|
new_message,
|
|
state_delta=None,
|
|
run_config: Optional[RunConfig] = None,
|
|
invocation_id: Optional[str] = None,
|
|
):
|
|
"""Mocked Runner.run_async that echoes input text back."""
|
|
# Extract the input text to echo it back — proves the pipeline works e2e
|
|
input_text = ""
|
|
if new_message and new_message.parts:
|
|
input_text = new_message.parts[0].text or ""
|
|
|
|
yield _model_event(f"Processed: {input_text}")
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
async def dummy_run_async_error(
|
|
self,
|
|
user_id,
|
|
session_id,
|
|
new_message,
|
|
state_delta=None,
|
|
run_config: Optional[RunConfig] = None,
|
|
invocation_id: Optional[str] = None,
|
|
):
|
|
"""Mocked Runner.run_async that raises an exception."""
|
|
raise RuntimeError("Agent crashed")
|
|
yield # make it an async generator # noqa: E305
|
|
|
|
|
|
def _make_rate_limit_runner(fail_count: int):
|
|
"""Create a runner that fails with 429 `fail_count` times, then succeeds."""
|
|
call_count = {"value": 0}
|
|
|
|
async def dummy_run_async_rate_limit(
|
|
self,
|
|
user_id,
|
|
session_id,
|
|
new_message,
|
|
state_delta=None,
|
|
run_config: Optional[RunConfig] = None,
|
|
invocation_id: Optional[str] = None,
|
|
):
|
|
call_count["value"] += 1
|
|
if call_count["value"] <= fail_count:
|
|
raise RuntimeError("429 Resource has been exhausted")
|
|
input_text = ""
|
|
if new_message and new_message.parts:
|
|
input_text = new_message.parts[0].text or ""
|
|
yield _model_event(f"Processed: {input_text}")
|
|
await asyncio.sleep(0)
|
|
|
|
return dummy_run_async_rate_limit
|
|
|
|
|
|
async def dummy_run_async_always_429(
|
|
self,
|
|
user_id,
|
|
session_id,
|
|
new_message,
|
|
state_delta=None,
|
|
run_config: Optional[RunConfig] = None,
|
|
invocation_id: Optional[str] = None,
|
|
):
|
|
"""Mocked Runner.run_async that always raises a 429 error."""
|
|
raise RuntimeError("RESOURCE_EXHAUSTED: 429 quota exceeded")
|
|
yield # noqa: E305
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def patch_runner(monkeypatch):
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_agent_loader():
|
|
|
|
class MockAgentLoader:
|
|
|
|
def __init__(self, agents_dir: str):
|
|
pass
|
|
|
|
def load_agent(self, app_name):
|
|
return root_agent
|
|
|
|
def list_agents(self):
|
|
return ["test_app"]
|
|
|
|
def list_agents_detailed(self):
|
|
return [{
|
|
"name": "test_app",
|
|
"root_agent_name": "trigger_test_agent",
|
|
"description": "Test agent for triggers",
|
|
"language": "python",
|
|
"is_computer_use": False,
|
|
}]
|
|
|
|
return MockAgentLoader(".")
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_session_service():
|
|
return InMemorySessionService()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_artifact_service():
|
|
service = AsyncMock()
|
|
service.list_artifact_keys = AsyncMock(return_value=[])
|
|
return service
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_memory_service():
|
|
return AsyncMock()
|
|
|
|
|
|
def _make_test_client(
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
trigger_sources: Optional[list[str]] = None,
|
|
) -> TestClient:
|
|
"""Build a TestClient with the given trigger setting."""
|
|
with (
|
|
patch.object(signal, "signal", autospec=True, return_value=None),
|
|
patch.object(
|
|
fast_api_module,
|
|
"create_session_service_from_options",
|
|
autospec=True,
|
|
return_value=mock_session_service,
|
|
),
|
|
patch.object(
|
|
fast_api_module,
|
|
"create_artifact_service_from_options",
|
|
autospec=True,
|
|
return_value=mock_artifact_service,
|
|
),
|
|
patch.object(
|
|
fast_api_module,
|
|
"create_memory_service_from_options",
|
|
autospec=True,
|
|
return_value=mock_memory_service,
|
|
),
|
|
patch.object(
|
|
fast_api_module,
|
|
"AgentLoader",
|
|
autospec=True,
|
|
return_value=mock_agent_loader,
|
|
),
|
|
patch.object(
|
|
fast_api_module,
|
|
"LocalEvalSetsManager",
|
|
autospec=True,
|
|
return_value=AsyncMock(),
|
|
),
|
|
patch.object(
|
|
fast_api_module,
|
|
"LocalEvalSetResultsManager",
|
|
autospec=True,
|
|
return_value=AsyncMock(),
|
|
),
|
|
):
|
|
app = get_fast_api_app(
|
|
agents_dir=".",
|
|
web=False,
|
|
session_service_uri="",
|
|
artifact_service_uri="",
|
|
memory_service_uri="",
|
|
allow_origins=["*"],
|
|
trigger_sources=trigger_sources,
|
|
)
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
):
|
|
"""TestClient with all triggers enabled."""
|
|
return _make_test_client(
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
trigger_sources=["pubsub", "eventarc"],
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def client_no_triggers(
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
):
|
|
"""TestClient with triggers disabled (default)."""
|
|
return _make_test_client(
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
trigger_sources=None,
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# /apps/test_app/trigger/pubsub — Pub/Sub Push Subscription
|
|
# ===================================================================
|
|
|
|
|
|
class TestTriggerPubSub:
|
|
"""Integration tests for the Pub/Sub push subscription trigger."""
|
|
|
|
def test_success(self, client, monkeypatch):
|
|
"""Valid Pub/Sub message is processed and returns success."""
|
|
captured_messages = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_messages.append(new_message.parts[0].text)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
message_data = base64.b64encode(b"Hello from Pub/Sub").decode("utf-8")
|
|
payload = {
|
|
"message": {
|
|
"data": message_data,
|
|
"messageId": "msg-001",
|
|
},
|
|
"subscription": "projects/my-project/subscriptions/my-sub",
|
|
}
|
|
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "success"
|
|
|
|
assert len(captured_messages) == 1
|
|
parsed_msg = json.loads(captured_messages[0])
|
|
assert parsed_msg["data"] == "Hello from Pub/Sub"
|
|
assert parsed_msg["attributes"] == {}
|
|
|
|
def test_message_with_attributes(self, client, monkeypatch):
|
|
"""Pub/Sub message with attributes (no data) is processed."""
|
|
captured_messages = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_messages.append(new_message.parts[0].text)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
payload = {
|
|
"message": {
|
|
"attributes": {"key": "value", "action": "process"},
|
|
"messageId": "msg-002",
|
|
},
|
|
}
|
|
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "success"
|
|
|
|
assert len(captured_messages) == 1
|
|
parsed_msg = json.loads(captured_messages[0])
|
|
assert parsed_msg["data"] is None
|
|
assert parsed_msg["attributes"] == {"key": "value", "action": "process"}
|
|
|
|
def test_json_payload_in_data(self, client, monkeypatch):
|
|
"""JSON-encoded data in Pub/Sub message is decoded properly."""
|
|
captured_messages = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_messages.append(new_message.parts[0].text)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
inner_json = json.dumps({"order_id": 42, "amount": 99.99})
|
|
message_data = base64.b64encode(inner_json.encode("utf-8")).decode("utf-8")
|
|
payload = {
|
|
"message": {
|
|
"data": message_data,
|
|
"messageId": "msg-003",
|
|
},
|
|
}
|
|
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "success"
|
|
|
|
assert len(captured_messages) == 1
|
|
parsed_msg = json.loads(captured_messages[0])
|
|
assert parsed_msg["data"] == {"order_id": 42, "amount": 99.99}
|
|
assert parsed_msg["attributes"] == {}
|
|
|
|
def test_invalid_base64_returns_400(self, client):
|
|
"""Invalid base64 data returns 400."""
|
|
payload = {
|
|
"message": {
|
|
"data": "!!!not-valid-base64!!!",
|
|
"messageId": "msg-bad",
|
|
},
|
|
}
|
|
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
|
|
|
|
assert resp.status_code == 400
|
|
assert "base64" in resp.json()["detail"].lower()
|
|
|
|
def test_agent_error_returns_500(self, client, monkeypatch):
|
|
"""Agent failure returns 500, allowing Pub/Sub to retry."""
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_error)
|
|
|
|
message_data = base64.b64encode(b"trigger error").decode("utf-8")
|
|
payload = {
|
|
"message": {"data": message_data},
|
|
}
|
|
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
|
|
|
|
assert resp.status_code == 500
|
|
assert "Agent processing failed" in resp.json()["detail"]
|
|
|
|
def test_with_subscription_metadata(self, client, monkeypatch):
|
|
"""Subscription field is sanitized for user_id (slashes replaced)."""
|
|
captured_user_ids = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_user_ids.append(user_id)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
message_data = base64.b64encode(b"test").decode("utf-8")
|
|
payload = {
|
|
"message": {"data": message_data},
|
|
"subscription": "projects/p/subscriptions/orders-sub",
|
|
}
|
|
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
|
|
|
|
assert resp.status_code == 200
|
|
assert len(captured_user_ids) == 1
|
|
assert captured_user_ids[0] == "projects--p--subscriptions--orders-sub"
|
|
assert "/" not in captured_user_ids[0]
|
|
|
|
def test_default_user_id_when_no_subscription(self, client, monkeypatch):
|
|
"""Default user_id is used when subscription is absent."""
|
|
captured_user_ids = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_user_ids.append(user_id)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
message_data = base64.b64encode(b"test").decode("utf-8")
|
|
payload = {
|
|
"message": {"data": message_data},
|
|
}
|
|
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
|
|
|
|
assert resp.status_code == 200
|
|
assert len(captured_user_ids) == 1
|
|
assert captured_user_ids[0] == "pubsub-caller"
|
|
|
|
def test_unknown_app_fails_early(
|
|
self, client, mock_agent_loader, mock_session_service
|
|
):
|
|
"""Unknown app fails early and does NOT create a session."""
|
|
|
|
def load_agent_raising(app_name):
|
|
if app_name == "unknown_app":
|
|
raise Exception("App not found")
|
|
return root_agent
|
|
|
|
mock_agent_loader.load_agent = load_agent_raising
|
|
|
|
message_data = base64.b64encode(b"test").decode("utf-8")
|
|
payload = {
|
|
"message": {"data": message_data},
|
|
}
|
|
resp = client.post("/apps/unknown_app/trigger/pubsub", json=payload)
|
|
|
|
assert resp.status_code == 500
|
|
assert "unknown_app" not in mock_session_service.sessions
|
|
|
|
|
|
# ===================================================================
|
|
# /apps/test_app/trigger/eventarc — Eventarc / CloudEvents
|
|
# ===================================================================
|
|
|
|
|
|
class TestTriggerEventarc:
|
|
"""Integration tests for the Eventarc / CloudEvents trigger."""
|
|
|
|
def test_success(self, client, monkeypatch):
|
|
"""Valid CloudEvent payload is processed and returns success."""
|
|
captured_messages = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_messages.append(new_message.parts[0].text)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
payload = {
|
|
"data": {
|
|
"bucket": "my-bucket",
|
|
"name": "path/to/file.pdf",
|
|
"contentType": "application/pdf",
|
|
},
|
|
"source": "storage.googleapis.com",
|
|
"type": "google.cloud.storage.object.v1.finalized",
|
|
"id": "evt-001",
|
|
"specversion": "1.0",
|
|
}
|
|
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "success"
|
|
|
|
assert len(captured_messages) == 1
|
|
parsed_msg = json.loads(captured_messages[0])
|
|
assert parsed_msg["data"] == payload["data"]
|
|
assert parsed_msg["attributes"]["ce-id"] == "evt-001"
|
|
assert (
|
|
parsed_msg["attributes"]["ce-type"]
|
|
== "google.cloud.storage.object.v1.finalized"
|
|
)
|
|
|
|
def test_source_derived_from_body_sanitized(self, client, monkeypatch):
|
|
"""Source from body is sanitized for user_id (slashes replaced)."""
|
|
captured_user_ids = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_user_ids.append(user_id)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
payload = {
|
|
"data": {"key": "value"},
|
|
"source": "//pubsub.googleapis.com/projects/p/topics/t",
|
|
}
|
|
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
|
|
|
|
assert resp.status_code == 200
|
|
assert len(captured_user_ids) == 1
|
|
assert (
|
|
captured_user_ids[0] == "pubsub.googleapis.com--projects--p--topics--t"
|
|
)
|
|
assert "/" not in captured_user_ids[0]
|
|
|
|
def test_source_from_ce_header_sanitized(self, client, monkeypatch):
|
|
"""ce-source header is sanitized for user_id (slashes replaced)."""
|
|
captured_user_ids = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_user_ids.append(user_id)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
payload = {
|
|
"data": {"key": "value"},
|
|
}
|
|
resp = client.post(
|
|
"/apps/test_app/trigger/eventarc",
|
|
json=payload,
|
|
headers={
|
|
"ce-source": (
|
|
"//storage.googleapis.com/projects/_/buckets/my-bucket"
|
|
),
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
assert len(captured_user_ids) == 1
|
|
assert (
|
|
captured_user_ids[0]
|
|
== "storage.googleapis.com--projects--_--buckets--my-bucket"
|
|
)
|
|
assert "/" not in captured_user_ids[0]
|
|
|
|
def test_default_user_id_when_no_source(self, client, monkeypatch):
|
|
"""Default user_id is used when source is absent."""
|
|
captured_user_ids = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_user_ids.append(user_id)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
payload = {
|
|
"data": {"key": "value"},
|
|
}
|
|
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
|
|
|
|
assert resp.status_code == 200
|
|
assert len(captured_user_ids) == 1
|
|
assert captured_user_ids[0] == "eventarc-caller"
|
|
|
|
def test_complex_event_data(self, client, monkeypatch):
|
|
"""Complex nested event data is serialized as JSON for the agent."""
|
|
captured_messages = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_messages.append(new_message.parts[0].text)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
payload = {
|
|
"data": {
|
|
"resource": {
|
|
"name": "projects/p/topics/t",
|
|
"labels": {"env": "prod"},
|
|
},
|
|
"insertId": "abc123",
|
|
"timestamp": "2026-01-01T00:00:00Z",
|
|
},
|
|
}
|
|
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "success"
|
|
|
|
assert len(captured_messages) == 1
|
|
parsed_msg = json.loads(captured_messages[0])
|
|
assert parsed_msg["data"] == payload["data"]
|
|
|
|
def test_agent_error_returns_500(self, client, monkeypatch):
|
|
"""Agent failure returns 500, allowing Eventarc to retry."""
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_error)
|
|
|
|
payload = {
|
|
"data": {"trigger": "error"},
|
|
}
|
|
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
|
|
|
|
assert resp.status_code == 500
|
|
assert "Agent processing failed" in resp.json()["detail"]
|
|
|
|
def test_minimal_payload(self, client, monkeypatch):
|
|
"""Minimal payload with just data field works."""
|
|
captured_messages = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_messages.append(new_message.parts[0].text)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
payload = {"data": {}}
|
|
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
|
|
assert resp.status_code == 200
|
|
|
|
assert len(captured_messages) == 1
|
|
parsed_msg = json.loads(captured_messages[0])
|
|
assert parsed_msg["data"] == {}
|
|
|
|
def test_structured_mode_pubsub_wrapper(self, client, monkeypatch):
|
|
"""Eventarc structured mode with Pub/Sub envelope is base64-decoded."""
|
|
captured_messages = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_messages.append(new_message.parts[0].text)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
inner_message = "Hello from structured Eventarc"
|
|
encoded_message = base64.b64encode(inner_message.encode("utf-8")).decode(
|
|
"utf-8"
|
|
)
|
|
payload = {
|
|
"data": {
|
|
"message": {
|
|
"data": encoded_message,
|
|
}
|
|
},
|
|
"source": "my-source",
|
|
}
|
|
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "success"
|
|
|
|
assert len(captured_messages) == 1
|
|
parsed_msg = json.loads(captured_messages[0])
|
|
assert parsed_msg["data"] == "Hello from structured Eventarc"
|
|
assert parsed_msg["attributes"] == {}
|
|
|
|
def test_binary_content_mode_pubsub_wrapper(self, client, monkeypatch):
|
|
"""Binary content mode: Pub/Sub message wrapper in body, CE attrs in headers."""
|
|
captured_messages = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_messages.append(new_message.parts[0].text)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
payload = {
|
|
"message": {
|
|
"data": base64.b64encode(b"hello from eventarc").decode(),
|
|
"messageId": "evt-msg-001",
|
|
},
|
|
"subscription": "projects/p/subscriptions/eventarc-sub",
|
|
}
|
|
resp = client.post(
|
|
"/apps/test_app/trigger/eventarc",
|
|
json=payload,
|
|
headers={
|
|
"ce-source": "//pubsub.googleapis.com/projects/p/topics/t",
|
|
"ce-type": "google.cloud.pubsub.topic.v1.messagePublished",
|
|
"ce-id": "binary-test-1",
|
|
"ce-specversion": "1.0",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "success"
|
|
|
|
assert len(captured_messages) == 1
|
|
parsed_msg = json.loads(captured_messages[0])
|
|
assert parsed_msg["data"] == "hello from eventarc"
|
|
assert parsed_msg["attributes"] == {}
|
|
|
|
def test_binary_content_mode_attributes_only(self, client, monkeypatch):
|
|
"""Binary content mode with attributes only (no data)."""
|
|
captured_messages = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_messages.append(new_message.parts[0].text)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
payload = {
|
|
"message": {
|
|
"attributes": {"key": "value"},
|
|
"messageId": "evt-msg-002",
|
|
},
|
|
}
|
|
resp = client.post(
|
|
"/apps/test_app/trigger/eventarc",
|
|
json=payload,
|
|
headers={"ce-source": "//pubsub.googleapis.com/test"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "success"
|
|
|
|
assert len(captured_messages) == 1
|
|
parsed_msg = json.loads(captured_messages[0])
|
|
assert parsed_msg["data"] is None
|
|
assert parsed_msg["attributes"] == {"key": "value"}
|
|
|
|
def test_binary_content_mode_arbitrary_payload(self, client, monkeypatch):
|
|
"""Binary content mode with arbitrary JSON payload (not Pub/Sub)."""
|
|
captured_message = []
|
|
|
|
async def dummy_run_async_capture(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
captured_message.append(new_message.parts[0].text)
|
|
yield _model_event("Success")
|
|
await asyncio.sleep(0)
|
|
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
|
|
|
|
payload = {
|
|
"bucket": "my-bucket",
|
|
"name": "file.txt",
|
|
"contentType": "application/json",
|
|
}
|
|
resp = client.post(
|
|
"/apps/test_app/trigger/eventarc",
|
|
json=payload,
|
|
headers={
|
|
"ce-source": (
|
|
"//storage.googleapis.com/projects/_/buckets/my-bucket"
|
|
),
|
|
"ce-type": "google.cloud.storage.object.v1.finalized",
|
|
"ce-id": "12345",
|
|
"ce-specversion": "1.0",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert len(captured_message) == 1
|
|
received_data = json.loads(captured_message[0])
|
|
assert received_data["data"]["bucket"] == "my-bucket"
|
|
assert received_data["data"]["name"] == "file.txt"
|
|
assert received_data["attributes"]["ce-id"] == "12345"
|
|
|
|
|
|
# ===================================================================
|
|
# Triggers disabled (default behavior)
|
|
# ===================================================================
|
|
|
|
|
|
class TestTriggersDisabled:
|
|
"""Verify trigger endpoints return 404 when not enabled."""
|
|
|
|
def test_pubsub_returns_404(self, client_no_triggers):
|
|
resp = client_no_triggers.post(
|
|
"/apps/test_app/trigger/pubsub",
|
|
json={"message": {"data": base64.b64encode(b"x").decode()}},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
def test_eventarc_returns_404(self, client_no_triggers):
|
|
resp = client_no_triggers.post(
|
|
"/apps/test_app/trigger/eventarc", json={"data": {}}
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ===================================================================
|
|
# Transient error detection
|
|
# ===================================================================
|
|
|
|
|
|
class TestTransientErrorDetection:
|
|
"""Unit tests for the _is_transient_error helper."""
|
|
|
|
def test_429_in_message(self):
|
|
assert _is_transient_error(RuntimeError("HTTP 429 Too Many Requests"))
|
|
|
|
def test_resource_exhausted(self):
|
|
assert _is_transient_error(RuntimeError("RESOURCE_EXHAUSTED"))
|
|
|
|
def test_rate_limit(self):
|
|
assert _is_transient_error(RuntimeError("rate limit exceeded"))
|
|
|
|
def test_quota(self):
|
|
assert _is_transient_error(RuntimeError("quota exceeded for project"))
|
|
|
|
def test_non_transient(self):
|
|
assert not _is_transient_error(RuntimeError("Agent crashed"))
|
|
|
|
def test_permission_denied(self):
|
|
assert not _is_transient_error(RuntimeError("PERMISSION_DENIED"))
|
|
|
|
|
|
# ===================================================================
|
|
# Retry with exponential backoff
|
|
# ===================================================================
|
|
|
|
|
|
class TestRetryLogic:
|
|
"""Integration tests for retry with exponential backoff on 429 errors."""
|
|
|
|
def test_pubsub_retry_exhausted_returns_500(self, client, monkeypatch):
|
|
"""Pub/Sub trigger returns 500 when retries are exhausted."""
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_always_429)
|
|
|
|
with patch(
|
|
"google.adk.cli.trigger_routes.asyncio.sleep", new_callable=AsyncMock
|
|
):
|
|
message_data = base64.b64encode(b"429 test").decode("utf-8")
|
|
payload = {"message": {"data": message_data}}
|
|
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
|
|
|
|
assert resp.status_code == 500
|
|
assert "Rate limit" in resp.json()["detail"]
|
|
|
|
def test_eventarc_retry_exhausted_returns_500(self, client, monkeypatch):
|
|
"""Eventarc trigger returns 500 when retries are exhausted."""
|
|
monkeypatch.setattr(Runner, "run_async", dummy_run_async_always_429)
|
|
|
|
with patch(
|
|
"google.adk.cli.trigger_routes.asyncio.sleep", new_callable=AsyncMock
|
|
):
|
|
payload = {"data": {"test": "429"}}
|
|
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
|
|
|
|
assert resp.status_code == 500
|
|
assert "Rate limit" in resp.json()["detail"]
|
|
|
|
def test_non_transient_error_not_retried(self, client, monkeypatch):
|
|
"""Non-429 errors are NOT retried — they fail immediately."""
|
|
call_count = 0
|
|
|
|
async def counting_error_runner(
|
|
self, user_id, session_id, new_message, **kwargs
|
|
):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
raise RuntimeError("PERMISSION_DENIED: no access")
|
|
yield # noqa: E305
|
|
|
|
monkeypatch.setattr(Runner, "run_async", counting_error_runner)
|
|
|
|
with patch(
|
|
"google.adk.cli.trigger_routes.asyncio.sleep", new_callable=AsyncMock
|
|
):
|
|
payload = {"data": {"test": True}}
|
|
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
|
|
|
|
assert resp.status_code == 500
|
|
# Non-transient errors should NOT be retried — only 1 call
|
|
assert call_count == 1
|
|
|
|
|
|
# ===================================================================
|
|
# Semaphore / concurrency control
|
|
# ===================================================================
|
|
|
|
|
|
class TestConcurrencyControl:
|
|
"""Tests for semaphore-based concurrency limiting."""
|
|
|
|
def test_concurrent_pubsub_and_eventarc(self, client):
|
|
"""Multiple trigger types can be called without semaphore starvation."""
|
|
# Pub/Sub
|
|
ps_resp = client.post(
|
|
"/apps/test_app/trigger/pubsub",
|
|
json={"message": {"data": base64.b64encode(b"ps").decode()}},
|
|
)
|
|
assert ps_resp.status_code == 200
|
|
|
|
# Eventarc
|
|
ea_resp = client.post(
|
|
"/apps/test_app/trigger/eventarc",
|
|
json={"data": {"key": "value"}},
|
|
)
|
|
assert ea_resp.status_code == 200
|
|
|
|
|
|
# ===================================================================
|
|
# Selective trigger registration
|
|
# ===================================================================
|
|
|
|
|
|
class TestSelectiveRegistration:
|
|
"""Tests that only requested trigger sources are registered."""
|
|
|
|
def test_only_pubsub(
|
|
self,
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
):
|
|
"""When trigger_sources=['pubsub'], only Pub/Sub is available."""
|
|
client = _make_test_client(
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
trigger_sources=["pubsub"],
|
|
)
|
|
# Pub/Sub should work
|
|
ps_resp = client.post(
|
|
"/apps/test_app/trigger/pubsub",
|
|
json={"message": {"data": base64.b64encode(b"test").decode()}},
|
|
)
|
|
assert ps_resp.status_code == 200
|
|
|
|
# Eventarc should NOT be available
|
|
ea_resp = client.post("/apps/test_app/trigger/eventarc", json={"data": {}})
|
|
assert ea_resp.status_code == 404
|
|
|
|
def test_only_eventarc(
|
|
self,
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
):
|
|
"""When trigger_sources=['eventarc'], only Eventarc is available."""
|
|
client = _make_test_client(
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
trigger_sources=["eventarc"],
|
|
)
|
|
# Eventarc should work
|
|
ea_resp = client.post(
|
|
"/apps/test_app/trigger/eventarc", json={"data": {"k": "v"}}
|
|
)
|
|
assert ea_resp.status_code == 200
|
|
|
|
# Pub/Sub should NOT be available
|
|
ps_resp = client.post(
|
|
"/apps/test_app/trigger/pubsub",
|
|
json={"message": {"data": base64.b64encode(b"x").decode()}},
|
|
)
|
|
assert ps_resp.status_code == 404
|
|
|
|
|
|
class TestUnknownTriggerSources:
|
|
"""Verify unknown trigger sources are filtered and warned about."""
|
|
|
|
def test_unknown_source_ignored(
|
|
self,
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
):
|
|
"""Unknown source is silently dropped; valid sources still work."""
|
|
client = _make_test_client(
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
trigger_sources=["unknown_source", "pubsub"],
|
|
)
|
|
# "pubsub" should still be registered
|
|
ps_resp = client.post(
|
|
"/apps/test_app/trigger/pubsub",
|
|
json={"message": {"data": base64.b64encode(b"test").decode()}},
|
|
)
|
|
assert ps_resp.status_code == 200
|
|
|
|
# "unknown_source" should NOT be registered
|
|
unknown_resp = client.post(
|
|
"/apps/test_app/trigger/unknown_source", json={"calls": [["test"]]}
|
|
)
|
|
assert unknown_resp.status_code == 404
|
|
|
|
def test_all_unknown_sources_results_in_no_endpoints(
|
|
self,
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
):
|
|
"""All invalid sources means no trigger endpoints registered."""
|
|
client = _make_test_client(
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
trigger_sources=["foo", "bar"],
|
|
)
|
|
unknown_resp = client.post(
|
|
"/apps/test_app/trigger/unknown_source", json={"calls": [["test"]]}
|
|
)
|
|
assert unknown_resp.status_code == 404
|
|
|
|
|
|
class TestTriggersDisabled:
|
|
"""Verify trigger endpoints return 404 when not enabled."""
|
|
|
|
def test_pubsub_returns_404(
|
|
self,
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
):
|
|
client = _make_test_client(
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
trigger_sources=[],
|
|
)
|
|
resp = client.post(
|
|
"/apps/test_app/trigger/pubsub",
|
|
json={"message": {"data": base64.b64encode(b"x").decode()}},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
def test_eventarc_returns_404(
|
|
self,
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
):
|
|
client = _make_test_client(
|
|
mock_session_service,
|
|
mock_artifact_service,
|
|
mock_memory_service,
|
|
mock_agent_loader,
|
|
trigger_sources=[],
|
|
)
|
|
resp = client.post("/apps/test_app/trigger/eventarc", json={"data": {}})
|
|
assert resp.status_code == 404
|