|
14 | 14 | @pytest.fixture |
15 | 15 | def mock_process(): |
16 | 16 | class MockProcess: |
| 17 | + returncode = 0 |
| 18 | + |
17 | 19 | def communicate(self, timeout=None): |
18 | | - return "command output", None |
| 20 | + return "command output", "" |
19 | 21 |
|
20 | 22 | return MockProcess() |
21 | 23 |
|
@@ -515,6 +517,135 @@ def mock_popen(args, **kwargs): |
515 | 517 | assert "--selector" not in args_list |
516 | 518 |
|
517 | 519 |
|
| 520 | +def test_success_returns_ok_even_with_stderr_noise( |
| 521 | + monkeypatch: MonkeyPatch, mock_fastmcp |
| 522 | +): |
| 523 | + """A successful command (returncode=0) returns 'OK' even when stderr has |
| 524 | + noise like urllib3 deprecation warnings — stderr is dropped on success.""" |
| 525 | + |
| 526 | + class MockProcessSuccessWithStderrWarning: |
| 527 | + returncode = 0 |
| 528 | + |
| 529 | + def communicate(self, timeout=None): |
| 530 | + return "", "urllib3 v2.0 only supports OpenSSL 1.1.1+: NotOpenSSLWarning" |
| 531 | + |
| 532 | + def mock_popen(args, **kwargs): |
| 533 | + return MockProcessSuccessWithStderrWarning() |
| 534 | + |
| 535 | + monkeypatch.setattr("subprocess.Popen", mock_popen) |
| 536 | + |
| 537 | + fastmcp, tools = mock_fastmcp |
| 538 | + register_dbt_cli_tools( |
| 539 | + fastmcp, |
| 540 | + mock_dbt_cli_config, |
| 541 | + disabled_tools=set(), |
| 542 | + enabled_tools=None, |
| 543 | + enabled_toolsets=set(), |
| 544 | + disabled_toolsets=set(), |
| 545 | + ) |
| 546 | + |
| 547 | + assert tools["build"]() == "OK" |
| 548 | + |
| 549 | + |
| 550 | +def test_failure_surfaces_stderr_when_stdout_is_empty( |
| 551 | + monkeypatch: MonkeyPatch, mock_fastmcp |
| 552 | +): |
| 553 | + """A failed command (returncode!=0) surfaces stderr — some dbt errors |
| 554 | + only appear there, e.g. authentication or connection problems.""" |
| 555 | + |
| 556 | + class MockProcessFailureStderrOnly: |
| 557 | + returncode = 1 |
| 558 | + |
| 559 | + def communicate(self, timeout=None): |
| 560 | + return "", "Database Error: could not connect to server" |
| 561 | + |
| 562 | + def mock_popen(args, **kwargs): |
| 563 | + return MockProcessFailureStderrOnly() |
| 564 | + |
| 565 | + monkeypatch.setattr("subprocess.Popen", mock_popen) |
| 566 | + |
| 567 | + fastmcp, tools = mock_fastmcp |
| 568 | + register_dbt_cli_tools( |
| 569 | + fastmcp, |
| 570 | + mock_dbt_cli_config, |
| 571 | + disabled_tools=set(), |
| 572 | + enabled_tools=None, |
| 573 | + enabled_toolsets=set(), |
| 574 | + disabled_toolsets=set(), |
| 575 | + ) |
| 576 | + |
| 577 | + result = tools["build"]() |
| 578 | + |
| 579 | + assert "Database Error" in result |
| 580 | + assert result != "OK" |
| 581 | + |
| 582 | + |
| 583 | +def test_failure_with_no_output_surfaces_exit_code( |
| 584 | + monkeypatch: MonkeyPatch, mock_fastmcp |
| 585 | +): |
| 586 | + """A failed command with empty stdout AND stderr must NOT return 'OK' — |
| 587 | + surface the exit code so the LLM can tell the call actually failed.""" |
| 588 | + |
| 589 | + class MockProcessFailureNoOutput: |
| 590 | + returncode = 137 # e.g. OOM-killed |
| 591 | + |
| 592 | + def communicate(self, timeout=None): |
| 593 | + return "", "" |
| 594 | + |
| 595 | + def mock_popen(args, **kwargs): |
| 596 | + return MockProcessFailureNoOutput() |
| 597 | + |
| 598 | + monkeypatch.setattr("subprocess.Popen", mock_popen) |
| 599 | + |
| 600 | + fastmcp, tools = mock_fastmcp |
| 601 | + register_dbt_cli_tools( |
| 602 | + fastmcp, |
| 603 | + mock_dbt_cli_config, |
| 604 | + disabled_tools=set(), |
| 605 | + enabled_tools=None, |
| 606 | + enabled_toolsets=set(), |
| 607 | + disabled_toolsets=set(), |
| 608 | + ) |
| 609 | + |
| 610 | + result = tools["build"]() |
| 611 | + |
| 612 | + assert result != "OK" |
| 613 | + assert "137" in result |
| 614 | + assert "exit code" in result.lower() |
| 615 | + |
| 616 | + |
| 617 | +def test_failure_combines_stdout_and_stderr(monkeypatch: MonkeyPatch, mock_fastmcp): |
| 618 | + """When both streams have content on failure, both are surfaced.""" |
| 619 | + |
| 620 | + class MockProcessFailureBothStreams: |
| 621 | + returncode = 1 |
| 622 | + |
| 623 | + def communicate(self, timeout=None): |
| 624 | + return "Compilation Error in model my_model", "Stack trace here" |
| 625 | + |
| 626 | + def mock_popen(args, **kwargs): |
| 627 | + return MockProcessFailureBothStreams() |
| 628 | + |
| 629 | + monkeypatch.setattr("subprocess.Popen", mock_popen) |
| 630 | + |
| 631 | + fastmcp, tools = mock_fastmcp |
| 632 | + register_dbt_cli_tools( |
| 633 | + fastmcp, |
| 634 | + mock_dbt_cli_config, |
| 635 | + disabled_tools=set(), |
| 636 | + enabled_tools=None, |
| 637 | + enabled_toolsets=set(), |
| 638 | + disabled_toolsets=set(), |
| 639 | + ) |
| 640 | + |
| 641 | + result = tools["build"]() |
| 642 | + |
| 643 | + assert "--- stdout ---" in result |
| 644 | + assert "Compilation Error" in result |
| 645 | + assert "--- stderr ---" in result |
| 646 | + assert "Stack trace" in result |
| 647 | + |
| 648 | + |
518 | 649 | def test_clone_command_binary_state_path_logic( |
519 | 650 | monkeypatch: MonkeyPatch, |
520 | 651 | mock_process, |
|
0 commit comments