/ tests / test_search_console_client.py
test_search_console_client.py
  1  """Search Console client unit tests
  2  
  3  Tests for SearchConsoleApiClient — each API method is tested
  4  with mocked httpx responses.
  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 httpx
 14  import pytest
 15  
 16  
 17  def _make_client(**kwargs: Any):
 18      """Create a SearchConsoleApiClient with mock credentials."""
 19      from mureo.search_console.client import SearchConsoleApiClient
 20  
 21      creds = MagicMock()
 22      creds.token = "fake-token"
 23      creds.expired = False
 24      return SearchConsoleApiClient(credentials=creds, **kwargs)
 25  
 26  
 27  def _mock_response(status_code: int = 200, json_data: Any = None) -> MagicMock:
 28      """Create a mock httpx.Response."""
 29      resp = MagicMock()
 30      resp.status_code = status_code
 31      resp.json.return_value = json_data or {}
 32      resp.text = json.dumps(json_data or {})
 33      resp.raise_for_status = MagicMock()
 34      if status_code >= 400:
 35          resp.raise_for_status.side_effect = httpx.HTTPStatusError(
 36              "error", request=MagicMock(), response=resp
 37          )
 38      return resp
 39  
 40  
 41  # ---------------------------------------------------------------------------
 42  # Initialization tests
 43  # ---------------------------------------------------------------------------
 44  
 45  
 46  @pytest.mark.unit
 47  class TestSearchConsoleClientInit:
 48      def test_valid_init(self) -> None:
 49          from mureo.search_console.client import SearchConsoleApiClient
 50  
 51          creds = MagicMock()
 52          creds.token = "tok"
 53          creds.expired = False
 54          client = SearchConsoleApiClient(credentials=creds)
 55          assert client._credentials is creds
 56  
 57      def test_with_throttler(self) -> None:
 58          from mureo.search_console.client import SearchConsoleApiClient
 59  
 60          creds = MagicMock()
 61          creds.token = "tok"
 62          creds.expired = False
 63          throttler = MagicMock()
 64          client = SearchConsoleApiClient(credentials=creds, throttler=throttler)
 65          assert client._throttler is throttler
 66  
 67  
 68  # ---------------------------------------------------------------------------
 69  # list_sites tests
 70  # ---------------------------------------------------------------------------
 71  
 72  
 73  @pytest.mark.unit
 74  class TestListSites:
 75      @pytest.mark.asyncio
 76      async def test_list_sites_success(self) -> None:
 77          client = _make_client()
 78          response_data = {
 79              "siteEntry": [
 80                  {"siteUrl": "https://example.com/", "permissionLevel": "siteOwner"},
 81                  {"siteUrl": "sc-domain:example.org", "permissionLevel": "siteOwner"},
 82              ]
 83          }
 84          mock_resp = _mock_response(200, response_data)
 85          client._http = MagicMock()
 86          client._http.get = AsyncMock(return_value=mock_resp)
 87  
 88          result = await client.list_sites()
 89          assert len(result) == 2
 90          assert result[0]["siteUrl"] == "https://example.com/"
 91  
 92      @pytest.mark.asyncio
 93      async def test_list_sites_empty(self) -> None:
 94          client = _make_client()
 95          mock_resp = _mock_response(200, {"siteEntry": []})
 96          client._http = MagicMock()
 97          client._http.get = AsyncMock(return_value=mock_resp)
 98  
 99          result = await client.list_sites()
100          assert result == []
101  
102      @pytest.mark.asyncio
103      async def test_list_sites_no_key(self) -> None:
104          """API returns empty dict when no sites."""
105          client = _make_client()
106          mock_resp = _mock_response(200, {})
107          client._http = MagicMock()
108          client._http.get = AsyncMock(return_value=mock_resp)
109  
110          result = await client.list_sites()
111          assert result == []
112  
113  
114  # ---------------------------------------------------------------------------
115  # get_site tests
116  # ---------------------------------------------------------------------------
117  
118  
119  @pytest.mark.unit
120  class TestGetSite:
121      @pytest.mark.asyncio
122      async def test_get_site_success(self) -> None:
123          client = _make_client()
124          response_data = {
125              "siteUrl": "https://example.com/",
126              "permissionLevel": "siteOwner",
127          }
128          mock_resp = _mock_response(200, response_data)
129          client._http = MagicMock()
130          client._http.get = AsyncMock(return_value=mock_resp)
131  
132          result = await client.get_site("https://example.com/")
133          assert result["siteUrl"] == "https://example.com/"
134  
135      @pytest.mark.asyncio
136      async def test_get_site_url_encoded(self) -> None:
137          """Site URL should be URL-encoded in the path."""
138          client = _make_client()
139          mock_resp = _mock_response(200, {"siteUrl": "https://example.com/"})
140          client._http = MagicMock()
141          client._http.get = AsyncMock(return_value=mock_resp)
142  
143          await client.get_site("https://example.com/")
144          call_args = client._http.get.call_args
145          url = call_args[0][0] if call_args[0] else call_args[1].get("url", "")
146          # URL should contain encoded site URL
147          assert "https%3A%2F%2Fexample.com%2F" in url or "sites/" in url
148  
149  
150  # ---------------------------------------------------------------------------
151  # query_analytics tests
152  # ---------------------------------------------------------------------------
153  
154  
155  @pytest.mark.unit
156  class TestQueryAnalytics:
157      @pytest.mark.asyncio
158      async def test_query_analytics_success(self) -> None:
159          client = _make_client()
160          response_data = {
161              "rows": [
162                  {
163                      "keys": ["example query"],
164                      "clicks": 100,
165                      "impressions": 1000,
166                      "ctr": 0.1,
167                      "position": 3.5,
168                  }
169              ]
170          }
171          mock_resp = _mock_response(200, response_data)
172          client._http = MagicMock()
173          client._http.post = AsyncMock(return_value=mock_resp)
174  
175          result = await client.query_analytics(
176              site_url="https://example.com/",
177              start_date="2026-01-01",
178              end_date="2026-01-31",
179              dimensions=["query"],
180          )
181          assert len(result) == 1
182          assert result[0]["clicks"] == 100
183  
184      @pytest.mark.asyncio
185      async def test_query_analytics_with_row_limit(self) -> None:
186          client = _make_client()
187          mock_resp = _mock_response(200, {"rows": []})
188          client._http = MagicMock()
189          client._http.post = AsyncMock(return_value=mock_resp)
190  
191          await client.query_analytics(
192              site_url="https://example.com/",
193              start_date="2026-01-01",
194              end_date="2026-01-31",
195              row_limit=50,
196          )
197          call_args = client._http.post.call_args
198          body = call_args[1].get("json", {}) if call_args[1] else {}
199          assert body.get("rowLimit") == 50
200  
201      @pytest.mark.asyncio
202      async def test_query_analytics_with_filters(self) -> None:
203          client = _make_client()
204          mock_resp = _mock_response(200, {"rows": []})
205          client._http = MagicMock()
206          client._http.post = AsyncMock(return_value=mock_resp)
207  
208          filters = [{"filters": [{"dimension": "query", "expression": "test"}]}]
209          await client.query_analytics(
210              site_url="https://example.com/",
211              start_date="2026-01-01",
212              end_date="2026-01-31",
213              dimension_filter_groups=filters,
214          )
215          call_args = client._http.post.call_args
216          body = call_args[1].get("json", {}) if call_args[1] else {}
217          assert "dimensionFilterGroups" in body
218  
219      @pytest.mark.asyncio
220      async def test_query_analytics_empty_rows(self) -> None:
221          client = _make_client()
222          mock_resp = _mock_response(200, {})
223          client._http = MagicMock()
224          client._http.post = AsyncMock(return_value=mock_resp)
225  
226          result = await client.query_analytics(
227              site_url="https://example.com/",
228              start_date="2026-01-01",
229              end_date="2026-01-31",
230          )
231          assert result == []
232  
233  
234  # ---------------------------------------------------------------------------
235  # list_sitemaps tests
236  # ---------------------------------------------------------------------------
237  
238  
239  @pytest.mark.unit
240  class TestListSitemaps:
241      @pytest.mark.asyncio
242      async def test_list_sitemaps_success(self) -> None:
243          client = _make_client()
244          response_data = {
245              "sitemap": [
246                  {
247                      "path": "https://example.com/sitemap.xml",
248                      "lastSubmitted": "2026-01-01T00:00:00Z",
249                  }
250              ]
251          }
252          mock_resp = _mock_response(200, response_data)
253          client._http = MagicMock()
254          client._http.get = AsyncMock(return_value=mock_resp)
255  
256          result = await client.list_sitemaps("https://example.com/")
257          assert len(result) == 1
258          assert result[0]["path"] == "https://example.com/sitemap.xml"
259  
260      @pytest.mark.asyncio
261      async def test_list_sitemaps_empty(self) -> None:
262          client = _make_client()
263          mock_resp = _mock_response(200, {})
264          client._http = MagicMock()
265          client._http.get = AsyncMock(return_value=mock_resp)
266  
267          result = await client.list_sitemaps("https://example.com/")
268          assert result == []
269  
270  
271  # ---------------------------------------------------------------------------
272  # submit_sitemap tests
273  # ---------------------------------------------------------------------------
274  
275  
276  @pytest.mark.unit
277  class TestSubmitSitemap:
278      @pytest.mark.asyncio
279      async def test_submit_sitemap_success(self) -> None:
280          client = _make_client()
281          mock_resp = _mock_response(200, {})
282          client._http = MagicMock()
283          client._http.put = AsyncMock(return_value=mock_resp)
284  
285          result = await client.submit_sitemap(
286              "https://example.com/", "https://example.com/sitemap.xml"
287          )
288          assert result == {
289              "status": "submitted",
290              "sitemap": "https://example.com/sitemap.xml",
291          }
292  
293  
294  # ---------------------------------------------------------------------------
295  # inspect_url tests
296  # ---------------------------------------------------------------------------
297  
298  
299  @pytest.mark.unit
300  class TestInspectUrl:
301      @pytest.mark.asyncio
302      async def test_inspect_url_success(self) -> None:
303          client = _make_client()
304          response_data = {
305              "inspectionResult": {
306                  "indexStatusResult": {
307                      "coverageState": "Submitted and indexed",
308                      "verdict": "PASS",
309                  }
310              }
311          }
312          mock_resp = _mock_response(200, response_data)
313          client._http = MagicMock()
314          client._http.post = AsyncMock(return_value=mock_resp)
315  
316          result = await client.inspect_url(
317              site_url="https://example.com/",
318              inspection_url="https://example.com/page",
319          )
320          assert "inspectionResult" in result
321  
322  
323  # ---------------------------------------------------------------------------
324  # Token refresh tests
325  # ---------------------------------------------------------------------------
326  
327  
328  @pytest.mark.unit
329  class TestTokenRefresh:
330      @pytest.mark.asyncio
331      async def test_refreshes_expired_token(self) -> None:
332          from mureo.search_console.client import SearchConsoleApiClient
333  
334          creds = MagicMock()
335          creds.token = "new-token"
336          creds.valid = False
337          creds.refresh = MagicMock()
338  
339          client = SearchConsoleApiClient(credentials=creds)
340          mock_resp = _mock_response(200, {"siteEntry": []})
341          client._http = MagicMock()
342          client._http.get = AsyncMock(return_value=mock_resp)
343  
344          await client.list_sites()
345          creds.refresh.assert_called_once()
346  
347  
348  # ---------------------------------------------------------------------------
349  # Throttler integration tests
350  # ---------------------------------------------------------------------------
351  
352  
353  @pytest.mark.unit
354  class TestThrottlerIntegration:
355      @pytest.mark.asyncio
356      async def test_throttler_acquire_called(self) -> None:
357          throttler = MagicMock()
358          throttler.acquire = AsyncMock()
359          client = _make_client(throttler=throttler)
360  
361          mock_resp = _mock_response(200, {"siteEntry": []})
362          client._http = MagicMock()
363          client._http.get = AsyncMock(return_value=mock_resp)
364  
365          await client.list_sites()
366          throttler.acquire.assert_awaited_once()
367  
368  
369  # ---------------------------------------------------------------------------
370  # Error handling tests
371  # ---------------------------------------------------------------------------
372  
373  
374  @pytest.mark.unit
375  class TestErrorHandling:
376      @pytest.mark.asyncio
377      async def test_http_error_propagates(self) -> None:
378          client = _make_client()
379          mock_resp = _mock_response(403, {"error": {"message": "Forbidden"}})
380          client._http = MagicMock()
381          client._http.get = AsyncMock(return_value=mock_resp)
382  
383          with pytest.raises(httpx.HTTPStatusError):
384              await client.list_sites()