/ tests / test_mcp_tools_search_console.py
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({})