diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index b53516d..65612d6 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -29,10 +29,18 @@ from google.adk.tools.function_tool import FunctionTool from google.adk.tools.mcp_tool.conversion_utils import adk_to_mcp_tool_type +from analytics_mcp.tools.admin.access import run_access_report from analytics_mcp.tools.admin.info import ( get_account_summaries, + get_data_retention_settings, list_google_ads_links, get_property_details, + list_audiences, + list_custom_dimensions, + list_custom_metrics, + list_data_streams, + list_key_events, + list_properties, list_property_annotations, ) from analytics_mcp.tools.reporting.core import ( @@ -45,7 +53,9 @@ ) from analytics_mcp.tools.reporting.metadata import ( get_custom_dimensions_and_metrics, + get_metadata, ) +from analytics_mcp.tools.reporting.quotas import get_property_quotas from analytics_mcp.tools.reporting.funnel import ( run_funnel_report, _run_funnel_report_description, @@ -75,8 +85,18 @@ FunctionTool(get_account_summaries), FunctionTool(list_google_ads_links), FunctionTool(get_property_details), + FunctionTool(get_data_retention_settings), + FunctionTool(list_audiences), + FunctionTool(list_custom_dimensions), + FunctionTool(list_custom_metrics), + FunctionTool(list_data_streams), + FunctionTool(list_key_events), + FunctionTool(list_properties), FunctionTool(list_property_annotations), + FunctionTool(run_access_report), FunctionTool(get_custom_dimensions_and_metrics), + FunctionTool(get_metadata), + FunctionTool(get_property_quotas), run_report_with_description, run_realtime_report_with_description, run_funnel_report_with_description, @@ -142,6 +162,13 @@ def sanitize_mcp_schema_properties(node: dict) -> None: ] elif tool.name == "run_realtime_report": tool.inputSchema["required"] = ["property_id", "dimensions", "metrics"] + elif tool.name == "run_access_report": + tool.inputSchema["required"] = [ + "entity", + "date_ranges", + "dimensions", + "metrics", + ] elif tool.name == "run_conversions_report": tool.inputSchema["required"] = [ "property_id", diff --git a/analytics_mcp/tools/admin/access.py b/analytics_mcp/tools/admin/access.py new file mode 100644 index 0000000..9a2dd86 --- /dev/null +++ b/analytics_mcp/tools/admin/access.py @@ -0,0 +1,99 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# 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. + +"""Tools for running data access reports using the Admin API.""" + +import asyncio +from typing import Any, Dict, List + +from analytics_mcp.tools.utils import ( + construct_account_rn, + construct_property_rn, + proto_to_dict, +) +from analytics_mcp.tools.client import create_admin_api_client +from google.analytics import admin_v1beta + + +def _construct_access_report_entity(entity: int | str) -> str: + """Returns the entity resource name for an access report request. + + Accepts a property (number or 'properties/N') or an account + ('accounts/N'). + """ + if isinstance(entity, str) and entity.strip().startswith("accounts/"): + return construct_account_rn(entity) + return construct_property_rn(entity) + + +async def run_access_report( + entity: int | str, + date_ranges: List[Dict[str, str]], + dimensions: List[str], + metrics: List[str], + limit: int = None, + offset: int = None, + return_entity_quota: bool = False, +) -> Dict[str, Any]: + """Runs a data access report showing who accessed Analytics data. + + Each row describes data access events, e.g. which user accessed + which property, when, from where, and how many data requests were + made. Useful for compliance and security auditing. + + Access reports use their own dimension and metric names, documented + at + https://developers.google.com/analytics/devguides/config/admin/v1/access-api-schema. + Common dimensions include `userEmail`, `epochTimeMicros`, + `accessMechanism`, and `reportType`. Common metrics include + `accessCount`. + + Args: + entity: The entity to report on. Accepted formats are: + - A number (treated as a property ID) + - A string consisting of 'properties/' followed by a number + - A string consisting of 'accounts/' followed by a number, + which reports on all properties under the account + date_ranges: A list of date range dicts, each with `start_date` + and `end_date` keys. Dates are in `YYYY-MM-DD` format, or the + relative forms `today`, `yesterday`, and `NdaysAgo`. + dimensions: A list of access report dimension names. + metrics: A list of access report metric names. + limit: The maximum number of rows to return (max 100,000). + offset: The row count of the start row (0-indexed). + return_entity_quota: Whether to include the current state of + this property's access report quota in the response. + """ + request = admin_v1beta.RunAccessReportRequest( + entity=_construct_access_report_entity(entity), + date_ranges=[admin_v1beta.AccessDateRange(dr) for dr in date_ranges], + dimensions=[ + admin_v1beta.AccessDimension(dimension_name=d) for d in dimensions + ], + metrics=[admin_v1beta.AccessMetric(metric_name=m) for m in metrics], + return_entity_quota=return_entity_quota, + ) + + if limit: + request.limit = limit + + if offset: + request.offset = offset + + def _sync_call(): + return create_admin_api_client().run_access_report(request=request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) diff --git a/analytics_mcp/tools/admin/info.py b/analytics_mcp/tools/admin/info.py index 04cc3ff..3a05dcc 100644 --- a/analytics_mcp/tools/admin/info.py +++ b/analytics_mcp/tools/admin/info.py @@ -18,6 +18,7 @@ from typing import Any, Dict, List from analytics_mcp.tools.utils import ( + construct_account_rn, construct_property_rn, proto_to_dict, ) @@ -78,6 +79,202 @@ def _sync_call(): return proto_to_dict(response) +async def list_properties( + account_id: int | str, show_deleted: bool = False +) -> List[Dict[str, Any]]: + """Returns the properties under a Google Analytics account. + + Returns full property objects, including display name, industry + category, time zone, currency code, service level, and create time. + Use this for deeper property discovery than `get_account_summaries`, + which only returns summary information. + + Args: + account_id: The Google Analytics account ID. Accepted formats are: + - A number + - A string consisting of 'accounts/' followed by a number + show_deleted: Whether to include soft-deleted (i.e. "trashed") + properties in the results. Defaults to False. + """ + request = admin_v1beta.ListPropertiesRequest( + filter=f"parent:{construct_account_rn(account_id)}", + show_deleted=show_deleted, + ) + + def _sync_call(): + properties_pager = create_admin_api_client().list_properties( + request=request + ) + return [proto_to_dict(prop) for prop in properties_pager] + + return await asyncio.to_thread(_sync_call) + + +async def list_key_events(property_id: int | str) -> List[Dict[str, Any]]: + """Returns the key events configured for a property. + + Key events (formerly known as conversion events) are the events that a + property has marked as most important, such as purchases or sign-ups. + Reports use key events to calculate conversion-related metrics. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = admin_v1beta.ListKeyEventsRequest( + parent=construct_property_rn(property_id) + ) + + def _sync_call(): + key_events_pager = create_admin_api_client().list_key_events( + request=request + ) + return [proto_to_dict(key_event) for key_event in key_events_pager] + + return await asyncio.to_thread(_sync_call) + + +async def list_data_streams(property_id: int | str) -> List[Dict[str, Any]]: + """Returns the data streams configured for a property. + + Data streams are the sources that send data to a property, such as a + web site, an Android app, or an iOS app. Each stream includes its + platform-specific details, e.g. the measurement ID for a web stream. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = admin_v1beta.ListDataStreamsRequest( + parent=construct_property_rn(property_id) + ) + + def _sync_call(): + data_streams_pager = create_admin_api_client().list_data_streams( + request=request + ) + return [ + proto_to_dict(data_stream) for data_stream in data_streams_pager + ] + + return await asyncio.to_thread(_sync_call) + + +async def list_audiences(property_id: int | str) -> List[Dict[str, Any]]: + """Returns the audiences defined on a property. + + Audiences are segments of users, e.g. "Purchasers" or "Users who + visited the pricing page". Knowing what audiences exist helps when + interpreting audience-scoped dimensions in reports or suggesting + targeting strategies. + + Note: this uses the alpha channel of the Admin API, which may + change without notice. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = admin_v1alpha.ListAudiencesRequest( + parent=construct_property_rn(property_id) + ) + + def _sync_call(): + audiences_pager = create_admin_alpha_api_client().list_audiences( + request=request + ) + return [proto_to_dict(audience) for audience in audiences_pager] + + return await asyncio.to_thread(_sync_call) + + +async def get_data_retention_settings( + property_id: int | str, +) -> Dict[str, Any]: + """Returns the data retention settings for a property. + + Shows how long event-level and user-level data is kept before being + automatically deleted. Useful for compliance questions and for + knowing how far back reports can reliably look: if retention is set + to TWO_MONTHS, event-scoped data older than that is unavailable in + explorations and some reports. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = admin_v1beta.GetDataRetentionSettingsRequest( + name=f"{construct_property_rn(property_id)}/dataRetentionSettings" + ) + + def _sync_call(): + client = create_admin_api_client() + return client.get_data_retention_settings(request=request) + + response = await asyncio.to_thread(_sync_call) + return proto_to_dict(response) + + +async def list_custom_dimensions( + property_id: int | str, +) -> List[Dict[str, Any]]: + """Returns the custom dimensions defined on a property (Admin API). + + Includes details that the Data API metadata endpoint doesn't return: + the dimension's scope (EVENT or USER), parameter name, description, + and whether it's excluded from ads personalization. Use the + `get_custom_dimensions_and_metrics` tool instead if you only need + the API names for building reports. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = admin_v1beta.ListCustomDimensionsRequest( + parent=construct_property_rn(property_id) + ) + + def _sync_call(): + dimensions_pager = create_admin_api_client().list_custom_dimensions( + request=request + ) + return [proto_to_dict(dimension) for dimension in dimensions_pager] + + return await asyncio.to_thread(_sync_call) + + +async def list_custom_metrics(property_id: int | str) -> List[Dict[str, Any]]: + """Returns the custom metrics defined on a property (Admin API). + + Includes details that the Data API metadata endpoint doesn't return: + the metric's scope, parameter name, description, measurement unit, + and restricted metric type. Use the + `get_custom_dimensions_and_metrics` tool instead if you only need + the API names for building reports. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = admin_v1beta.ListCustomMetricsRequest( + parent=construct_property_rn(property_id) + ) + + def _sync_call(): + metrics_pager = create_admin_api_client().list_custom_metrics( + request=request + ) + return [proto_to_dict(metric) for metric in metrics_pager] + + return await asyncio.to_thread(_sync_call) + + async def list_property_annotations( property_id: int | str, ) -> List[Dict[str, Any]]: diff --git a/analytics_mcp/tools/reporting/metadata.py b/analytics_mcp/tools/reporting/metadata.py index af8263c..5df8f5f 100644 --- a/analytics_mcp/tools/reporting/metadata.py +++ b/analytics_mcp/tools/reporting/metadata.py @@ -474,6 +474,33 @@ def get_order_bys_hints(): """ +async def get_metadata(property_id: int | str) -> Dict[str, Any]: + """Returns the full dimension and metric catalog for a property. + + Includes every dimension and metric available in reports for the + property — both standard and custom — with API names, display names, + descriptions, category groupings, and deprecation status. Use this + to discover what fields are available before building a report. + + If you only need the property's custom definitions, use the + `get_custom_dimensions_and_metrics` tool instead, since its response + is much smaller. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + + def _sync_call(): + return create_data_api_client().get_metadata( + name=f"{construct_property_rn(property_id)}/metadata" + ) + + metadata = await asyncio.to_thread(_sync_call) + return proto_to_dict(metadata) + + async def get_custom_dimensions_and_metrics( property_id: int | str, ) -> Dict[str, List[Dict[str, Any]]]: diff --git a/analytics_mcp/tools/reporting/quotas.py b/analytics_mcp/tools/reporting/quotas.py new file mode 100644 index 0000000..b6866cb --- /dev/null +++ b/analytics_mcp/tools/reporting/quotas.py @@ -0,0 +1,54 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# 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. + +"""Tools for inspecting Data API quota usage.""" + +import asyncio +from typing import Any, Dict + +from analytics_mcp.tools.utils import ( + construct_property_rn, + proto_to_dict, +) +from analytics_mcp.tools.client import create_data_api_alpha_client +from google.analytics import data_v1alpha + + +async def get_property_quotas(property_id: int | str) -> Dict[str, Any]: + """Returns the current Data API quota usage for a property. + + Shows consumed and remaining quota for core tokens (per day and per + hour), realtime tokens, funnel tokens, concurrent requests, and + server errors. Check this before running expensive report batches + so you can back off instead of hitting quota errors. + + Note: this uses the alpha channel of the Data API, which may change + without notice. + + Args: + property_id: The Google Analytics property ID. Accepted formats are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = data_v1alpha.GetPropertyQuotasSnapshotRequest( + name=f"{construct_property_rn(property_id)}/propertyQuotasSnapshot" + ) + + def _sync_call(): + client = create_data_api_alpha_client() + return client.get_property_quotas_snapshot(request=request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) diff --git a/analytics_mcp/tools/utils.py b/analytics_mcp/tools/utils.py index 66852f3..e439ddb 100644 --- a/analytics_mcp/tools/utils.py +++ b/analytics_mcp/tools/utils.py @@ -44,6 +44,31 @@ def construct_property_rn(property_value: int | str) -> str: return f"properties/{property_num}" +def construct_account_rn(account_value: int | str) -> str: + """Returns an account resource name in the format required by APIs.""" + account_num = None + if isinstance(account_value, int): + account_num = account_value + elif isinstance(account_value, str): + account_value = account_value.strip() + if account_value.isdigit(): + account_num = int(account_value) + elif account_value.startswith("accounts/"): + numeric_part = account_value.split("/")[-1] + if numeric_part.isdigit(): + account_num = int(numeric_part) + if account_num is None: + raise ValueError( + ( + f"Invalid account ID: {account_value}. " + "A valid account value is either a number or a string starting " + "with 'accounts/' and followed by a number." + ) + ) + + return f"accounts/{account_num}" + + def proto_to_dict(obj: proto.Message) -> Dict[str, Any]: """Converts a proto message to a dictionary.""" return type(obj).to_dict( diff --git a/tests/access_test.py b/tests/access_test.py new file mode 100644 index 0000000..08a933b --- /dev/null +++ b/tests/access_test.py @@ -0,0 +1,137 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# 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. + +"""Test cases for the run_access_report tool.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import admin_v1beta + +from analytics_mcp.tools.admin.access import ( + _construct_access_report_entity, + run_access_report, +) + + +class TestConstructAccessReportEntity(unittest.TestCase): + """Test cases for _construct_access_report_entity.""" + + def test_property_forms(self): + """Tests property ID forms resolve to a property entity.""" + self.assertEqual( + _construct_access_report_entity(12345), "properties/12345" + ) + self.assertEqual( + _construct_access_report_entity("properties/12345"), + "properties/12345", + ) + + def test_account_form(self): + """Tests an 'accounts/' string resolves to an account entity.""" + self.assertEqual( + _construct_access_report_entity("accounts/678"), "accounts/678" + ) + + def test_invalid_entity_raises(self): + """Tests that invalid entities raise a ValueError.""" + with self.assertRaises(ValueError): + _construct_access_report_entity("bogus") + with self.assertRaises(ValueError): + _construct_access_report_entity("accounts/abc") + + +class TestRunAccessReport(unittest.TestCase): + """Test cases for run_access_report.""" + + @patch("analytics_mcp.tools.admin.access.create_admin_api_client") + def test_builds_request(self, mock_create_client): + """Tests that the request proto is built correctly.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.run_access_report.return_value = ( + admin_v1beta.RunAccessReportResponse(row_count=0) + ) + + asyncio.run( + run_access_report( + 12345, + date_ranges=[{"start_date": "7daysAgo", "end_date": "today"}], + dimensions=["userEmail", "accessMechanism"], + metrics=["accessCount"], + limit=50, + ) + ) + + request = mock_client.run_access_report.call_args.kwargs["request"] + self.assertEqual(request.entity, "properties/12345") + self.assertEqual(len(request.date_ranges), 1) + self.assertEqual(request.date_ranges[0].start_date, "7daysAgo") + self.assertEqual( + [d.dimension_name for d in request.dimensions], + ["userEmail", "accessMechanism"], + ) + self.assertEqual( + [m.metric_name for m in request.metrics], ["accessCount"] + ) + self.assertEqual(request.limit, 50) + self.assertFalse(request.return_entity_quota) + + @patch("analytics_mcp.tools.admin.access.create_admin_api_client") + def test_account_level_report(self, mock_create_client): + """Tests that account-level entities are passed through.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.run_access_report.return_value = ( + admin_v1beta.RunAccessReportResponse() + ) + + asyncio.run( + run_access_report( + "accounts/99", + date_ranges=[ + {"start_date": "2025-01-01", "end_date": "2025-01-31"} + ], + dimensions=["userEmail"], + metrics=["accessCount"], + ) + ) + + request = mock_client.run_access_report.call_args.kwargs["request"] + self.assertEqual(request.entity, "accounts/99") + + @patch("analytics_mcp.tools.admin.access.create_admin_api_client") + def test_converts_response(self, mock_create_client): + """Tests that the proto response is converted to a dict.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.run_access_report.return_value = ( + admin_v1beta.RunAccessReportResponse(row_count=2) + ) + + result = asyncio.run( + run_access_report( + 12345, + date_ranges=[{"start_date": "yesterday", "end_date": "today"}], + dimensions=["userEmail"], + metrics=["accessCount"], + ) + ) + + self.assertEqual(result["row_count"], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/info_test.py b/tests/info_test.py new file mode 100644 index 0000000..2c03ad1 --- /dev/null +++ b/tests/info_test.py @@ -0,0 +1,283 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# 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. + +"""Test cases for the admin info tools.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import admin_v1alpha, admin_v1beta + +from analytics_mcp.tools.admin.info import ( + get_data_retention_settings, + list_audiences, + list_custom_dimensions, + list_custom_metrics, + list_data_streams, + list_key_events, + list_properties, +) + + +class TestListKeyEvents(unittest.TestCase): + """Test cases for list_key_events.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_returns_key_events(self, mock_create_client): + """Tests that key events are listed and converted to dicts.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + key_event = admin_v1beta.KeyEvent( + name="properties/12345/keyEvents/67890", + event_name="purchase", + counting_method=( + admin_v1beta.KeyEvent.CountingMethod.ONCE_PER_EVENT + ), + ) + mock_client.list_key_events.return_value = [key_event] + + result = asyncio.run(list_key_events(12345)) + + request = mock_client.list_key_events.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["event_name"], "purchase") + self.assertEqual(result[0]["name"], "properties/12345/keyEvents/67890") + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_accepts_property_rn_string(self, mock_create_client): + """Tests that a 'properties/' prefixed string is accepted.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.list_key_events.return_value = [] + + result = asyncio.run(list_key_events("properties/9876")) + + request = mock_client.list_key_events.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/9876") + self.assertEqual(result, []) + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(list_key_events("not-a-property")) + + +class TestListDataStreams(unittest.TestCase): + """Test cases for list_data_streams.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_returns_data_streams(self, mock_create_client): + """Tests that data streams are listed and converted to dicts.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + web_stream = admin_v1beta.DataStream( + name="properties/12345/dataStreams/111", + type_=admin_v1beta.DataStream.DataStreamType.WEB_DATA_STREAM, + display_name="Web stream", + web_stream_data=admin_v1beta.DataStream.WebStreamData( + measurement_id="G-ABC123", + default_uri="https://www.example.com", + ), + ) + mock_client.list_data_streams.return_value = [web_stream] + + result = asyncio.run(list_data_streams(12345)) + + request = mock_client.list_data_streams.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["display_name"], "Web stream") + self.assertEqual( + result[0]["web_stream_data"]["measurement_id"], "G-ABC123" + ) + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(list_data_streams("bogus")) + + +class TestListProperties(unittest.TestCase): + """Test cases for list_properties.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_returns_properties(self, mock_create_client): + """Tests that properties are listed with an account filter.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + prop = admin_v1beta.Property( + name="properties/12345", + display_name="My property", + time_zone="America/New_York", + currency_code="USD", + ) + mock_client.list_properties.return_value = [prop] + + result = asyncio.run(list_properties(98765)) + + request = mock_client.list_properties.call_args.kwargs["request"] + self.assertEqual(request.filter, "parent:accounts/98765") + self.assertFalse(request.show_deleted) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["display_name"], "My property") + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_show_deleted_passthrough(self, mock_create_client): + """Tests that show_deleted is passed through to the request.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.list_properties.return_value = [] + + asyncio.run(list_properties("accounts/55", show_deleted=True)) + + request = mock_client.list_properties.call_args.kwargs["request"] + self.assertEqual(request.filter, "parent:accounts/55") + self.assertTrue(request.show_deleted) + + def test_invalid_account_id_raises(self): + """Tests that an invalid account ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(list_properties("properties/123")) + + +class TestListCustomDimensions(unittest.TestCase): + """Test cases for list_custom_dimensions.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_returns_custom_dimensions(self, mock_create_client): + """Tests that custom dimensions include Admin API detail.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + dimension = admin_v1beta.CustomDimension( + name="properties/12345/customDimensions/1", + parameter_name="plan_type", + display_name="Plan type", + description="The subscription plan type.", + scope=admin_v1beta.CustomDimension.DimensionScope.EVENT, + ) + mock_client.list_custom_dimensions.return_value = [dimension] + + result = asyncio.run(list_custom_dimensions(12345)) + + request = mock_client.list_custom_dimensions.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["parameter_name"], "plan_type") + self.assertEqual(result[0]["scope"], "EVENT") + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(list_custom_dimensions("bogus")) + + +class TestListCustomMetrics(unittest.TestCase): + """Test cases for list_custom_metrics.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_returns_custom_metrics(self, mock_create_client): + """Tests that custom metrics include Admin API detail.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + metric = admin_v1beta.CustomMetric( + name="properties/12345/customMetrics/1", + parameter_name="loyalty_points", + display_name="Loyalty points", + measurement_unit=( + admin_v1beta.CustomMetric.MeasurementUnit.STANDARD + ), + scope=admin_v1beta.CustomMetric.MetricScope.EVENT, + ) + mock_client.list_custom_metrics.return_value = [metric] + + result = asyncio.run(list_custom_metrics(12345)) + + request = mock_client.list_custom_metrics.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["parameter_name"], "loyalty_points") + self.assertEqual(result[0]["measurement_unit"], "STANDARD") + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(list_custom_metrics("bogus")) + + +class TestGetDataRetentionSettings(unittest.TestCase): + """Test cases for get_data_retention_settings.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_api_client") + def test_returns_settings(self, mock_create_client): + """Tests that retention settings are fetched and converted.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + settings = admin_v1beta.DataRetentionSettings( + name="properties/12345/dataRetentionSettings", + event_data_retention=( + admin_v1beta.DataRetentionSettings.RetentionDuration.FOURTEEN_MONTHS + ), + reset_user_data_on_new_activity=True, + ) + mock_client.get_data_retention_settings.return_value = settings + + result = asyncio.run(get_data_retention_settings(12345)) + + request = mock_client.get_data_retention_settings.call_args.kwargs[ + "request" + ] + self.assertEqual(request.name, "properties/12345/dataRetentionSettings") + self.assertEqual(result["event_data_retention"], "FOURTEEN_MONTHS") + self.assertTrue(result["reset_user_data_on_new_activity"]) + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(get_data_retention_settings("bogus")) + + +class TestListAudiences(unittest.TestCase): + """Test cases for list_audiences.""" + + @patch("analytics_mcp.tools.admin.info.create_admin_alpha_api_client") + def test_returns_audiences(self, mock_create_client): + """Tests that audiences are listed via the alpha client.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + audience = admin_v1alpha.Audience( + name="properties/12345/audiences/777", + display_name="Purchasers", + description="Users who purchased.", + membership_duration_days=30, + ) + mock_client.list_audiences.return_value = [audience] + + result = asyncio.run(list_audiences(12345)) + + request = mock_client.list_audiences.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["display_name"], "Purchasers") + self.assertEqual(result[0]["membership_duration_days"], 30) + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(list_audiences("bogus")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/metadata_test.py b/tests/metadata_test.py new file mode 100644 index 0000000..4adb807 --- /dev/null +++ b/tests/metadata_test.py @@ -0,0 +1,109 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# 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. + +"""Test cases for the reporting metadata tools.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import data_v1beta + +from analytics_mcp.tools.reporting.metadata import ( + get_custom_dimensions_and_metrics, + get_metadata, +) + + +def _sample_metadata() -> data_v1beta.Metadata: + """Returns a Metadata proto with standard and custom entries.""" + return data_v1beta.Metadata( + name="properties/12345/metadata", + dimensions=[ + data_v1beta.DimensionMetadata( + api_name="country", + ui_name="Country", + description="The country from which activity originated.", + category="Geography", + custom_definition=False, + ), + data_v1beta.DimensionMetadata( + api_name="customEvent:plan_type", + ui_name="Plan type", + description="Custom event-scoped dimension.", + category="Custom", + custom_definition=True, + ), + ], + metrics=[ + data_v1beta.MetricMetadata( + api_name="activeUsers", + ui_name="Active users", + description="The number of distinct active users.", + category="User", + custom_definition=False, + ), + ], + ) + + +class TestGetMetadata(unittest.TestCase): + """Test cases for get_metadata.""" + + @patch("analytics_mcp.tools.reporting.metadata.create_data_api_client") + def test_returns_full_catalog(self, mock_create_client): + """Tests that the full catalog is returned, not just custom.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.get_metadata.return_value = _sample_metadata() + + result = asyncio.run(get_metadata(12345)) + + mock_client.get_metadata.assert_called_once_with( + name="properties/12345/metadata" + ) + dimension_names = [d["api_name"] for d in result["dimensions"]] + self.assertIn("country", dimension_names) + self.assertIn("customEvent:plan_type", dimension_names) + self.assertEqual(result["metrics"][0]["api_name"], "activeUsers") + self.assertEqual(result["dimensions"][0]["category"], "Geography") + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(get_metadata("bogus")) + + +class TestGetCustomDimensionsAndMetrics(unittest.TestCase): + """Test cases for get_custom_dimensions_and_metrics.""" + + @patch("analytics_mcp.tools.reporting.metadata.create_data_api_client") + def test_returns_only_custom_definitions(self, mock_create_client): + """Tests that only custom definitions are returned.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.get_metadata.return_value = _sample_metadata() + + result = asyncio.run(get_custom_dimensions_and_metrics(12345)) + + self.assertEqual(len(result["custom_dimensions"]), 1) + self.assertEqual( + result["custom_dimensions"][0]["api_name"], + "customEvent:plan_type", + ) + self.assertEqual(result["custom_metrics"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/quotas_test.py b/tests/quotas_test.py new file mode 100644 index 0000000..4bdf347 --- /dev/null +++ b/tests/quotas_test.py @@ -0,0 +1,67 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# 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. + +"""Test cases for the get_property_quotas tool.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import data_v1alpha + +from analytics_mcp.tools.reporting.quotas import get_property_quotas + + +class TestGetPropertyQuotas(unittest.TestCase): + """Test cases for get_property_quotas.""" + + @patch("analytics_mcp.tools.reporting.quotas.create_data_api_alpha_client") + def test_returns_quota_snapshot(self, mock_create_client): + """Tests that the quota snapshot is fetched and converted.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + snapshot = data_v1alpha.PropertyQuotasSnapshot( + name="properties/12345/propertyQuotasSnapshot", + core_property_quota=data_v1alpha.PropertyQuota( + tokens_per_day=data_v1alpha.QuotaStatus( + consumed=100, remaining=199900 + ), + ), + ) + mock_client.get_property_quotas_snapshot.return_value = snapshot + + result = asyncio.run(get_property_quotas(12345)) + + request = mock_client.get_property_quotas_snapshot.call_args.kwargs[ + "request" + ] + self.assertEqual( + request.name, "properties/12345/propertyQuotasSnapshot" + ) + self.assertEqual( + result["core_property_quota"]["tokens_per_day"]["consumed"], 100 + ) + self.assertEqual( + result["core_property_quota"]["tokens_per_day"]["remaining"], + 199900, + ) + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(get_property_quotas("bogus")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/utils_test.py b/tests/utils_test.py index a521836..4d6d677 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -68,3 +68,45 @@ def test_construct_property_rn_invalid_input(self): msg="Resource name with more than 2 components should fail", ): utils.construct_property_rn("properties/123/abc") + + def test_construct_account_rn(self): + """Tests construct_account_rn using valid input.""" + self.assertEqual( + utils.construct_account_rn(12345), + "accounts/12345", + "Numeric account ID should be considered valid", + ) + self.assertEqual( + utils.construct_account_rn("12345"), + "accounts/12345", + "Numeric account ID as string should be considered valid", + ) + self.assertEqual( + utils.construct_account_rn(" 12345 "), + "accounts/12345", + "Whitespace around account ID should be considered valid", + ) + self.assertEqual( + utils.construct_account_rn("accounts/12345"), + "accounts/12345", + "Full resource name should be considered valid", + ) + + def test_construct_account_rn_invalid_input(self): + """Tests that construct_account_rn raises a ValueError for invalid input.""" + with self.assertRaises(ValueError, msg="None should fail"): + utils.construct_account_rn(None) + with self.assertRaises(ValueError, msg="Empty string should fail"): + utils.construct_account_rn("") + with self.assertRaises( + ValueError, msg="Non-numeric string should fail" + ): + utils.construct_account_rn("abc") + with self.assertRaises( + ValueError, msg="Resource name without ID should fail" + ): + utils.construct_account_rn("accounts/") + with self.assertRaises( + ValueError, msg="Property resource name should fail" + ): + utils.construct_account_rn("properties/123")