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()