test_mcp_tools_search_console.py
1 """Search Console MCP tool definitions and handler tests 2 3 Tests for tool definitions (inputSchema, required fields) and 4 handlers (mock client). 5 """ 6 7 from __future__ import annotations 8 9 import json 10 from typing import Any 11 from unittest.mock import AsyncMock, MagicMock, patch 12 13 import pytest 14 15 16 def _import_tools(): 17 from mureo.mcp import tools_search_console 18 19 return tools_search_console 20 21 22 def _import_handlers(): 23 from mureo.mcp import _handlers_search_console 24 25 return _handlers_search_console 26 27 28 # --------------------------------------------------------------------------- 29 # Tool definition tests 30 # --------------------------------------------------------------------------- 31 32 33 @pytest.mark.unit 34 class TestSearchConsoleToolDefinitions: 35 """Verify Search Console tool definitions are correct.""" 36 37 def test_tool_count(self) -> None: 38 """All 10 tools are defined.""" 39 mod = _import_tools() 40 assert len(mod.TOOLS) == 10 41 42 def test_all_tool_names_prefixed(self) -> None: 43 """All tool names start with search_console.""" 44 mod = _import_tools() 45 for tool in mod.TOOLS: 46 assert tool.name.startswith( 47 "search_console." 48 ), f"Invalid tool name: {tool.name}" 49 50 def test_all_tools_have_input_schema(self) -> None: 51 """All tools have a valid inputSchema.""" 52 mod = _import_tools() 53 for tool in mod.TOOLS: 54 assert tool.inputSchema["type"] == "object" 55 assert "properties" in tool.inputSchema 56 57 @pytest.mark.parametrize( 58 "tool_name,expected_required", 59 [ 60 ("search_console.sites.list", []), 61 ("search_console.sites.get", ["site_url"]), 62 ( 63 "search_console.analytics.query", 64 ["site_url", "start_date", "end_date"], 65 ), 66 ( 67 "search_console.analytics.top_queries", 68 ["site_url", "start_date", "end_date"], 69 ), 70 ( 71 "search_console.analytics.top_pages", 72 ["site_url", "start_date", "end_date"], 73 ), 74 ( 75 "search_console.analytics.device_breakdown", 76 ["site_url", "start_date", "end_date"], 77 ), 78 ( 79 "search_console.analytics.compare_periods", 80 [ 81 "site_url", 82 "start_date_1", 83 "end_date_1", 84 "start_date_2", 85 "end_date_2", 86 ], 87 ), 88 ("search_console.sitemaps.list", ["site_url"]), 89 ("search_console.sitemaps.submit", ["site_url", "feedpath"]), 90 ( 91 "search_console.url_inspection.inspect", 92 ["site_url", "inspection_url"], 93 ), 94 ], 95 ) 96 def test_required_fields( 97 self, tool_name: str, expected_required: list[str] 98 ) -> None: 99 """Each tool has the correct required fields.""" 100 mod = _import_tools() 101 tool = next((t for t in mod.TOOLS if t.name == tool_name), None) 102 assert tool is not None, f"Tool {tool_name} not found" 103 actual = set(tool.inputSchema.get("required", [])) 104 assert actual == set(expected_required) 105 106 def test_tool_names_unique(self) -> None: 107 """All tool names are unique.""" 108 mod = _import_tools() 109 names = [t.name for t in mod.TOOLS] 110 assert len(names) == len(set(names)) 111 112 113 # --------------------------------------------------------------------------- 114 # Handler dispatch tests 115 # --------------------------------------------------------------------------- 116 117 118 @pytest.mark.unit 119 class TestHandlerDispatch: 120 async def test_unknown_tool_raises(self) -> None: 121 mod = _import_tools() 122 with pytest.raises(ValueError, match="Unknown tool"): 123 await mod.handle_tool("search_console.nonexistent", {}) 124 125 async def test_dispatch_sites_list(self) -> None: 126 mod = _import_tools() 127 handler_mod = _import_handlers() 128 mock_creds = MagicMock() 129 mock_client = AsyncMock() 130 mock_client.list_sites.return_value = [{"siteUrl": "https://example.com/"}] 131 with ( 132 patch.object( 133 handler_mod, "load_google_ads_credentials", return_value=mock_creds 134 ), 135 patch.object( 136 handler_mod, 137 "create_search_console_client", 138 return_value=mock_client, 139 ), 140 ): 141 result = await mod.handle_tool("search_console.sites.list", {}) 142 143 assert len(result) == 1 144 parsed = json.loads(result[0].text) 145 assert parsed[0]["siteUrl"] == "https://example.com/" 146 147 148 # --------------------------------------------------------------------------- 149 # Individual handler tests 150 # --------------------------------------------------------------------------- 151 152 153 def _setup_handler_mocks(handler_mod: Any) -> tuple[MagicMock, AsyncMock]: 154 """Return (mock_creds, mock_client) for handler tests.""" 155 mock_creds = MagicMock() 156 mock_client = AsyncMock() 157 return mock_creds, mock_client 158 159 160 @pytest.mark.unit 161 class TestSitesHandlers: 162 async def test_sites_list(self) -> None: 163 h = _import_handlers() 164 mock_creds, mock_client = _setup_handler_mocks(h) 165 mock_client.list_sites.return_value = [{"siteUrl": "https://example.com/"}] 166 with ( 167 patch.object(h, "load_google_ads_credentials", return_value=mock_creds), 168 patch.object(h, "create_search_console_client", return_value=mock_client), 169 ): 170 result = await h.handle_sites_list({}) 171 172 parsed = json.loads(result[0].text) 173 assert len(parsed) == 1 174 mock_client.list_sites.assert_awaited_once() 175 176 async def test_sites_get(self) -> None: 177 h = _import_handlers() 178 mock_creds, mock_client = _setup_handler_mocks(h) 179 mock_client.get_site.return_value = { 180 "siteUrl": "https://example.com/", 181 "permissionLevel": "siteOwner", 182 } 183 with ( 184 patch.object(h, "load_google_ads_credentials", return_value=mock_creds), 185 patch.object(h, "create_search_console_client", return_value=mock_client), 186 ): 187 result = await h.handle_sites_get({"site_url": "https://example.com/"}) 188 189 parsed = json.loads(result[0].text) 190 assert parsed["permissionLevel"] == "siteOwner" 191 192 async def test_no_credentials(self) -> None: 193 h = _import_handlers() 194 with patch.object(h, "load_google_ads_credentials", return_value=None): 195 result = await h.handle_sites_list({}) 196 197 assert "Credentials not found" in result[0].text 198 199 200 # --------------------------------------------------------------------------- 201 # Analytics handler tests 202 # --------------------------------------------------------------------------- 203 204 205 @pytest.mark.unit 206 class TestAnalyticsHandlers: 207 async def test_query_analytics(self) -> None: 208 h = _import_handlers() 209 mock_creds, mock_client = _setup_handler_mocks(h) 210 mock_client.query_analytics.return_value = [ 211 {"keys": ["test"], "clicks": 50, "impressions": 500} 212 ] 213 with ( 214 patch.object(h, "load_google_ads_credentials", return_value=mock_creds), 215 patch.object(h, "create_search_console_client", return_value=mock_client), 216 ): 217 result = await h.handle_analytics_query( 218 { 219 "site_url": "https://example.com/", 220 "start_date": "2026-01-01", 221 "end_date": "2026-01-31", 222 "dimensions": ["query"], 223 "row_limit": 100, 224 } 225 ) 226 227 parsed = json.loads(result[0].text) 228 assert parsed[0]["clicks"] == 50 229 230 async def test_top_queries(self) -> None: 231 h = _import_handlers() 232 mock_creds, mock_client = _setup_handler_mocks(h) 233 mock_client.query_analytics.return_value = [{"keys": ["query1"], "clicks": 100}] 234 with ( 235 patch.object(h, "load_google_ads_credentials", return_value=mock_creds), 236 patch.object(h, "create_search_console_client", return_value=mock_client), 237 ): 238 result = await h.handle_analytics_top_queries( 239 { 240 "site_url": "https://example.com/", 241 "start_date": "2026-01-01", 242 "end_date": "2026-01-31", 243 } 244 ) 245 246 parsed = json.loads(result[0].text) 247 assert len(parsed) >= 1 248 # Should have called with dimensions=["query"] 249 call_kwargs = mock_client.query_analytics.call_args[1] 250 assert call_kwargs["dimensions"] == ["query"] 251 252 async def test_top_pages(self) -> None: 253 h = _import_handlers() 254 mock_creds, mock_client = _setup_handler_mocks(h) 255 mock_client.query_analytics.return_value = [{"keys": ["/page1"], "clicks": 200}] 256 with ( 257 patch.object(h, "load_google_ads_credentials", return_value=mock_creds), 258 patch.object(h, "create_search_console_client", return_value=mock_client), 259 ): 260 result = await h.handle_analytics_top_pages( 261 { 262 "site_url": "https://example.com/", 263 "start_date": "2026-01-01", 264 "end_date": "2026-01-31", 265 } 266 ) 267 268 call_kwargs = mock_client.query_analytics.call_args[1] 269 assert call_kwargs["dimensions"] == ["page"] 270 271 async def test_device_breakdown(self) -> None: 272 h = _import_handlers() 273 mock_creds, mock_client = _setup_handler_mocks(h) 274 mock_client.query_analytics.return_value = [{"keys": ["MOBILE"], "clicks": 300}] 275 with ( 276 patch.object(h, "load_google_ads_credentials", return_value=mock_creds), 277 patch.object(h, "create_search_console_client", return_value=mock_client), 278 ): 279 result = await h.handle_analytics_device_breakdown( 280 { 281 "site_url": "https://example.com/", 282 "start_date": "2026-01-01", 283 "end_date": "2026-01-31", 284 } 285 ) 286 287 call_kwargs = mock_client.query_analytics.call_args[1] 288 assert call_kwargs["dimensions"] == ["device"] 289 290 async def test_compare_periods(self) -> None: 291 h = _import_handlers() 292 mock_creds, mock_client = _setup_handler_mocks(h) 293 mock_client.query_analytics.side_effect = [ 294 [{"keys": ["q1"], "clicks": 100}], 295 [{"keys": ["q1"], "clicks": 150}], 296 ] 297 with ( 298 patch.object(h, "load_google_ads_credentials", return_value=mock_creds), 299 patch.object(h, "create_search_console_client", return_value=mock_client), 300 ): 301 result = await h.handle_analytics_compare_periods( 302 { 303 "site_url": "https://example.com/", 304 "start_date_1": "2026-01-01", 305 "end_date_1": "2026-01-31", 306 "start_date_2": "2026-02-01", 307 "end_date_2": "2026-02-28", 308 } 309 ) 310 311 parsed = json.loads(result[0].text) 312 assert "period_1" in parsed 313 assert "period_2" in parsed 314 assert mock_client.query_analytics.await_count == 2 315 316 317 # --------------------------------------------------------------------------- 318 # Sitemap handler tests 319 # --------------------------------------------------------------------------- 320 321 322 @pytest.mark.unit 323 class TestSitemapHandlers: 324 async def test_list_sitemaps(self) -> None: 325 h = _import_handlers() 326 mock_creds, mock_client = _setup_handler_mocks(h) 327 mock_client.list_sitemaps.return_value = [ 328 {"path": "https://example.com/sitemap.xml"} 329 ] 330 with ( 331 patch.object(h, "load_google_ads_credentials", return_value=mock_creds), 332 patch.object(h, "create_search_console_client", return_value=mock_client), 333 ): 334 result = await h.handle_sitemaps_list({"site_url": "https://example.com/"}) 335 336 parsed = json.loads(result[0].text) 337 assert len(parsed) == 1 338 339 async def test_submit_sitemap(self) -> None: 340 h = _import_handlers() 341 mock_creds, mock_client = _setup_handler_mocks(h) 342 mock_client.submit_sitemap.return_value = { 343 "status": "submitted", 344 "sitemap": "https://example.com/sitemap.xml", 345 } 346 with ( 347 patch.object(h, "load_google_ads_credentials", return_value=mock_creds), 348 patch.object(h, "create_search_console_client", return_value=mock_client), 349 ): 350 result = await h.handle_sitemaps_submit( 351 { 352 "site_url": "https://example.com/", 353 "feedpath": "https://example.com/sitemap.xml", 354 } 355 ) 356 357 parsed = json.loads(result[0].text) 358 assert parsed["status"] == "submitted" 359 360 361 # --------------------------------------------------------------------------- 362 # URL inspection handler tests 363 # --------------------------------------------------------------------------- 364 365 366 @pytest.mark.unit 367 class TestUrlInspectionHandler: 368 async def test_inspect_url(self) -> None: 369 h = _import_handlers() 370 mock_creds, mock_client = _setup_handler_mocks(h) 371 mock_client.inspect_url.return_value = { 372 "inspectionResult": {"indexStatusResult": {"verdict": "PASS"}} 373 } 374 with ( 375 patch.object(h, "load_google_ads_credentials", return_value=mock_creds), 376 patch.object(h, "create_search_console_client", return_value=mock_client), 377 ): 378 result = await h.handle_url_inspection_inspect( 379 { 380 "site_url": "https://example.com/", 381 "inspection_url": "https://example.com/page", 382 } 383 ) 384 385 parsed = json.loads(result[0].text) 386 assert parsed["inspectionResult"]["indexStatusResult"]["verdict"] == "PASS" 387 388 async def test_inspect_url_missing_params(self) -> None: 389 h = _import_handlers() 390 mock_creds = MagicMock() 391 with patch.object(h, "load_google_ads_credentials", return_value=mock_creds): 392 with pytest.raises(ValueError, match="inspection_url"): 393 await h.handle_url_inspection_inspect( 394 {"site_url": "https://example.com/"} 395 ) 396 397 398 # --------------------------------------------------------------------------- 399 # Error handling tests 400 # --------------------------------------------------------------------------- 401 402 403 @pytest.mark.unit 404 class TestHandlerErrors: 405 async def test_api_error_returns_text(self) -> None: 406 """API errors are caught and returned as text.""" 407 h = _import_handlers() 408 mock_creds, mock_client = _setup_handler_mocks(h) 409 mock_client.list_sites.side_effect = RuntimeError("API boom") 410 with ( 411 patch.object(h, "load_google_ads_credentials", return_value=mock_creds), 412 patch.object(h, "create_search_console_client", return_value=mock_client), 413 ): 414 result = await h.handle_sites_list({}) 415 416 assert "API error" in result[0].text 417 418 async def test_missing_required_raises_value_error(self) -> None: 419 """Missing required param raises ValueError (not caught by decorator).""" 420 h = _import_handlers() 421 mock_creds = MagicMock() 422 with patch.object(h, "load_google_ads_credentials", return_value=mock_creds): 423 with pytest.raises(ValueError, match="site_url"): 424 await h.handle_sites_get({})