|
3 | 3 |
|
4 | 4 | import pytest
|
5 | 5 | from hamcrest import assert_that, equal_to, has_entries, has_length
|
| 6 | +from httpx import HTTPStatusError |
6 | 7 |
|
7 | 8 | from nodestream.pipeline.extractors.stores.splunk_extractor import SplunkExtractor
|
8 | 9 |
|
@@ -109,6 +110,16 @@ def test_splunk_extractor_auth_property_with_basic_auth(splunk_extractor_basic_a
|
109 | 110 | assert_that(auth is not None, equal_to(True))
|
110 | 111 |
|
111 | 112 |
|
| 113 | +def test_splunk_extractor_auth_property_returns_none_when_no_credentials(): |
| 114 | + """Test _auth property returns None when no credentials are provided.""" |
| 115 | + extractor = SplunkExtractor.from_file_data( |
| 116 | + base_url="https://splunk.example.com:8089", |
| 117 | + query="index=main", |
| 118 | + # No auth_token, username, or password provided |
| 119 | + ) |
| 120 | + assert_that(extractor._auth, equal_to(None)) |
| 121 | + |
| 122 | + |
112 | 123 | def test_splunk_extractor_headers_with_token(splunk_extractor):
|
113 | 124 | headers = splunk_extractor._headers
|
114 | 125 | assert_that(
|
@@ -212,6 +223,20 @@ async def test_splunk_extractor_create_search_job_malformed_xml(
|
212 | 223 | await splunk_extractor._create_search_job(mock_client)
|
213 | 224 |
|
214 | 225 |
|
| 226 | +@pytest.mark.asyncio |
| 227 | +async def test_splunk_extractor_create_search_job_http_error(splunk_extractor, mocker): |
| 228 | + """Test job creation when Splunk returns non-201 status code.""" |
| 229 | + mock_response = mocker.MagicMock() |
| 230 | + mock_response.status_code = 400 |
| 231 | + mock_response.request = mocker.MagicMock() |
| 232 | + |
| 233 | + mock_client = mocker.MagicMock() |
| 234 | + mock_client.post = AsyncMock(return_value=mock_response) |
| 235 | + |
| 236 | + with pytest.raises(HTTPStatusError, match="Failed to create search job: 400"): |
| 237 | + await splunk_extractor._create_search_job(mock_client) |
| 238 | + |
| 239 | + |
215 | 240 | @pytest.mark.asyncio
|
216 | 241 | async def test_splunk_extractor_wait_for_job_completion_json_response(
|
217 | 242 | splunk_extractor, mocker
|
@@ -269,10 +294,75 @@ async def test_splunk_extractor_wait_for_job_completion_xml_fallback(
|
269 | 294 | )
|
270 | 295 |
|
271 | 296 |
|
| 297 | +@pytest.mark.asyncio |
| 298 | +async def test_splunk_extractor_wait_for_job_completion_http_error( |
| 299 | + splunk_extractor, mocker |
| 300 | +): |
| 301 | + """Test job status checking when Splunk returns non-200 status code.""" |
| 302 | + mock_response = mocker.MagicMock() |
| 303 | + mock_response.status_code = 403 |
| 304 | + mock_response.request = mocker.MagicMock() |
| 305 | + |
| 306 | + mock_client = mocker.MagicMock() |
| 307 | + mock_client.get = AsyncMock(return_value=mock_response) |
| 308 | + |
| 309 | + with pytest.raises(HTTPStatusError, match="Failed to check job status: 403"): |
| 310 | + await splunk_extractor._wait_for_job_completion(mock_client, "test123") |
| 311 | + |
| 312 | + |
| 313 | +@pytest.mark.asyncio |
| 314 | +async def test_splunk_extractor_wait_for_job_completion_parse_error_warning( |
| 315 | + splunk_extractor, mocker |
| 316 | +): |
| 317 | + """Test job status checking when both JSON and XML parsing fail.""" |
| 318 | + mock_response = mocker.MagicMock() |
| 319 | + mock_response.status_code = 200 |
| 320 | + mock_response.text = "Invalid response format" |
| 321 | + mock_response.json.side_effect = json.JSONDecodeError("Not JSON", "", 0) |
| 322 | + |
| 323 | + mock_client = mocker.MagicMock() |
| 324 | + mock_client.get = AsyncMock(return_value=mock_response) |
| 325 | + |
| 326 | + # Mock the logger to verify warning is logged |
| 327 | + with patch.object(splunk_extractor, "logger") as mock_logger: |
| 328 | + with pytest.raises(RuntimeError, match="Search job timed out"): |
| 329 | + await splunk_extractor._wait_for_job_completion( |
| 330 | + mock_client, "test123", max_wait_seconds=1 |
| 331 | + ) |
| 332 | + |
| 333 | + # Verify warning was logged for parse failure |
| 334 | + mock_logger.warning.assert_called() |
| 335 | + warning_call = mock_logger.warning.call_args |
| 336 | + assert "Failed to parse job status" in warning_call[0][0] |
| 337 | + |
| 338 | + |
| 339 | +@pytest.mark.asyncio |
| 340 | +async def test_splunk_extractor_wait_for_job_completion_timeout( |
| 341 | + splunk_extractor, mocker |
| 342 | +): |
| 343 | + """Test job status checking timeout.""" |
| 344 | + mock_response = mocker.MagicMock() |
| 345 | + mock_response.status_code = 200 |
| 346 | + mock_response.json.return_value = { |
| 347 | + "entry": [{"content": {"dispatchState": "RUNNING"}}] |
| 348 | + } |
| 349 | + |
| 350 | + mock_client = mocker.MagicMock() |
| 351 | + mock_client.get = AsyncMock(return_value=mock_response) |
| 352 | + |
| 353 | + with pytest.raises( |
| 354 | + RuntimeError, match="Search job timed out after 1 seconds: test123" |
| 355 | + ): |
| 356 | + await splunk_extractor._wait_for_job_completion( |
| 357 | + mock_client, "test123", max_wait_seconds=1 |
| 358 | + ) |
| 359 | + |
| 360 | + |
272 | 361 | @pytest.mark.asyncio
|
273 | 362 | async def test_splunk_extractor_wait_for_job_completion_failure(
|
274 | 363 | splunk_extractor, mocker
|
275 | 364 | ):
|
| 365 | + """Test job status checking when job fails.""" |
276 | 366 | mock_response = mocker.MagicMock()
|
277 | 367 | mock_response.status_code = 200
|
278 | 368 | mock_response.json.return_value = {
|
@@ -372,6 +462,21 @@ async def test_splunk_extractor_get_job_results_pagination(splunk_extractor, moc
|
372 | 462 | assert_that(splunk_extractor.is_done, equal_to(True))
|
373 | 463 |
|
374 | 464 |
|
| 465 | +@pytest.mark.asyncio |
| 466 | +async def test_splunk_extractor_get_job_results_http_error(splunk_extractor, mocker): |
| 467 | + """Test results retrieval when Splunk returns non-200 status code.""" |
| 468 | + mock_response = mocker.MagicMock() |
| 469 | + mock_response.status_code = 404 |
| 470 | + mock_response.request = mocker.MagicMock() |
| 471 | + |
| 472 | + mock_client = mocker.MagicMock() |
| 473 | + mock_client.get = AsyncMock(return_value=mock_response) |
| 474 | + |
| 475 | + with pytest.raises(HTTPStatusError, match="Failed to get job results: 404"): |
| 476 | + async for _ in splunk_extractor._get_job_results(mock_client, "test123"): |
| 477 | + pass |
| 478 | + |
| 479 | + |
375 | 480 | # Full extraction test
|
376 | 481 | @pytest.mark.asyncio
|
377 | 482 | async def test_splunk_extractor_extract_records_full_flow(splunk_extractor, mocker):
|
@@ -442,3 +547,111 @@ async def test_splunk_extractor_resume_from_checkpoint(splunk_extractor):
|
442 | 547 | assert_that(splunk_extractor.search_id, equal_to("restored123"))
|
443 | 548 | assert_that(splunk_extractor.offset, equal_to(50))
|
444 | 549 | assert_that(splunk_extractor.is_done, equal_to(False))
|
| 550 | + |
| 551 | + |
| 552 | +@pytest.mark.asyncio |
| 553 | +async def test_splunk_extractor_extract_records_http_error_handling( |
| 554 | + splunk_extractor, mocker |
| 555 | +): |
| 556 | + """Test extract_records handles HTTPStatusError and logs properly.""" |
| 557 | + with patch( |
| 558 | + "nodestream.pipeline.extractors.stores.splunk_extractor.AsyncClient" |
| 559 | + ) as mock_client_class: |
| 560 | + mock_client = mocker.MagicMock() |
| 561 | + mock_client_class.return_value.__aenter__.return_value = mock_client |
| 562 | + mock_client_class.return_value.__aexit__.return_value = None |
| 563 | + |
| 564 | + # Mock HTTPStatusError during job creation |
| 565 | + mock_response = mocker.MagicMock() |
| 566 | + mock_response.status_code = 401 |
| 567 | + mock_response.text = "Unauthorized" |
| 568 | + mock_response.request = mocker.MagicMock() |
| 569 | + mock_response.request.url = "https://splunk.example.com/jobs" |
| 570 | + |
| 571 | + http_error = HTTPStatusError( |
| 572 | + "Unauthorized", request=mock_response.request, response=mock_response |
| 573 | + ) |
| 574 | + mock_client.post = AsyncMock(side_effect=http_error) |
| 575 | + |
| 576 | + # Mock the logger to verify error logging |
| 577 | + with patch.object(splunk_extractor, "logger") as mock_logger: |
| 578 | + with pytest.raises(HTTPStatusError): |
| 579 | + async for _ in splunk_extractor.extract_records(): |
| 580 | + pass |
| 581 | + |
| 582 | + # Verify HTTPStatusError was logged with proper details |
| 583 | + mock_logger.error.assert_called() |
| 584 | + error_call = mock_logger.error.call_args |
| 585 | + assert "Splunk request failed" in error_call[0][0] |
| 586 | + assert error_call[1]["extra"]["status_code"] == 401 |
| 587 | + assert error_call[1]["extra"]["content"] == "Unauthorized" |
| 588 | + |
| 589 | + |
| 590 | +@pytest.mark.asyncio |
| 591 | +async def test_splunk_extractor_extract_records_generic_exception_handling( |
| 592 | + splunk_extractor, mocker |
| 593 | +): |
| 594 | + """Test extract_records handles generic exceptions and logs properly.""" |
| 595 | + with patch( |
| 596 | + "nodestream.pipeline.extractors.stores.splunk_extractor.AsyncClient" |
| 597 | + ) as mock_client_class: |
| 598 | + mock_client = mocker.MagicMock() |
| 599 | + mock_client_class.return_value.__aenter__.return_value = mock_client |
| 600 | + mock_client_class.return_value.__aexit__.return_value = None |
| 601 | + |
| 602 | + # Mock a generic exception during job creation |
| 603 | + generic_error = ValueError("Something went wrong") |
| 604 | + mock_client.post = AsyncMock(side_effect=generic_error) |
| 605 | + |
| 606 | + # Mock the logger to verify error logging |
| 607 | + with patch.object(splunk_extractor, "logger") as mock_logger: |
| 608 | + with pytest.raises(ValueError): |
| 609 | + async for _ in splunk_extractor.extract_records(): |
| 610 | + pass |
| 611 | + |
| 612 | + # Verify generic exception was logged with proper details |
| 613 | + mock_logger.error.assert_called() |
| 614 | + error_call = mock_logger.error.call_args |
| 615 | + assert "Unexpected error during extraction" in error_call[0][0] |
| 616 | + assert error_call[1]["extra"]["error"] == "Something went wrong" |
| 617 | + assert error_call[1]["extra"]["error_type"] == "ValueError" |
| 618 | + |
| 619 | + |
| 620 | +@pytest.mark.asyncio |
| 621 | +async def test_splunk_extractor_extract_records_http_error_without_text_attribute( |
| 622 | + splunk_extractor, mocker |
| 623 | +): |
| 624 | + """Test HTTPStatusError handling when response doesn't have text attribute.""" |
| 625 | + with patch( |
| 626 | + "nodestream.pipeline.extractors.stores.splunk_extractor.AsyncClient" |
| 627 | + ) as mock_client_class: |
| 628 | + mock_client = mocker.MagicMock() |
| 629 | + mock_client_class.return_value.__aenter__.return_value = mock_client |
| 630 | + mock_client_class.return_value.__aexit__.return_value = None |
| 631 | + |
| 632 | + # Mock HTTPStatusError with response that doesn't have text attribute |
| 633 | + mock_response = mocker.MagicMock() |
| 634 | + mock_response.status_code = 500 |
| 635 | + # Remove text attribute to test fallback |
| 636 | + del mock_response.text |
| 637 | + mock_response.request = mocker.MagicMock() |
| 638 | + mock_response.request.url = "https://splunk.example.com/jobs" |
| 639 | + |
| 640 | + http_error = HTTPStatusError( |
| 641 | + "Server Error", request=mock_response.request, response=mock_response |
| 642 | + ) |
| 643 | + mock_client.post = AsyncMock(side_effect=http_error) |
| 644 | + |
| 645 | + # Mock the logger to verify error logging |
| 646 | + with patch.object(splunk_extractor, "logger") as mock_logger: |
| 647 | + with pytest.raises(HTTPStatusError): |
| 648 | + async for _ in splunk_extractor.extract_records(): |
| 649 | + pass |
| 650 | + |
| 651 | + # Verify HTTPStatusError was logged with str(response) fallback |
| 652 | + mock_logger.error.assert_called() |
| 653 | + error_call = mock_logger.error.call_args |
| 654 | + assert "Splunk request failed" in error_call[0][0] |
| 655 | + assert error_call[1]["extra"]["status_code"] == 500 |
| 656 | + # Should use str(response) when text attribute is missing |
| 657 | + assert str(mock_response) in error_call[1]["extra"]["content"] |
0 commit comments