|
12 | 12 | from knack.util import CLIError |
13 | 13 | from azure.cli.core.azclierror import (InvalidArgumentValueError, |
14 | 14 | MutuallyExclusiveArgumentError, |
15 | | - AzureResponseError) |
| 15 | + AzureResponseError, |
| 16 | + ResourceNotFoundError, |
| 17 | + ValidationError) |
16 | 18 | from azure.cli.command_modules.appservice.custom import (set_deployment_user, |
17 | 19 | update_git_token, add_hostname, |
18 | 20 | update_site_configs, |
|
33 | 35 | add_github_actions, |
34 | 36 | update_app_settings, |
35 | 37 | update_application_settings_polling, |
36 | | - update_webapp) |
| 38 | + update_webapp, |
| 39 | + _send_deploy_request) |
37 | 40 |
|
38 | 41 | # pylint: disable=line-too-long |
39 | 42 | from azure.cli.core.profiles import ResourceType |
@@ -639,6 +642,118 @@ def test_update_webapp_platform_release_channel_latest(self): |
639 | 642 | self.assertEqual(result.additional_properties["properties"]["platformReleaseChannel"], "Latest") |
640 | 643 |
|
641 | 644 |
|
| 645 | +class TestSendDeployRequest(unittest.TestCase): |
| 646 | + """Tests for _send_deploy_request wrapper that provides actionable error messages.""" |
| 647 | + |
| 648 | + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') |
| 649 | + def test_success_passes_through(self, send_raw_request_mock): |
| 650 | + """Successful responses (200/202) should pass through unchanged.""" |
| 651 | + cli_ctx = _get_test_cmd().cli_ctx |
| 652 | + response = mock.MagicMock() |
| 653 | + response.status_code = 202 |
| 654 | + send_raw_request_mock.return_value = response |
| 655 | + |
| 656 | + result = _send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}') |
| 657 | + |
| 658 | + self.assertEqual(result, response) |
| 659 | + send_raw_request_mock.assert_called_once_with( |
| 660 | + cli_ctx, "PUT", 'https://management.azure.com/deploy', body='{"properties":{}}' |
| 661 | + ) |
| 662 | + |
| 663 | + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') |
| 664 | + def test_400_with_empty_body_gives_actionable_error(self, send_raw_request_mock): |
| 665 | + """HTTP 400 with empty body should produce a helpful error message instead of bare 'Bad Request'.""" |
| 666 | + from azure.cli.core.azclierror import HTTPError |
| 667 | + cli_ctx = _get_test_cmd().cli_ctx |
| 668 | + |
| 669 | + response = mock.MagicMock() |
| 670 | + response.status_code = 400 |
| 671 | + response.reason = "Bad Request" |
| 672 | + response.text = "" |
| 673 | + send_raw_request_mock.side_effect = HTTPError("Bad Request", response) |
| 674 | + |
| 675 | + with self.assertRaises(CLIError) as ctx: |
| 676 | + _send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}') |
| 677 | + |
| 678 | + error_msg = str(ctx.exception) |
| 679 | + self.assertIn("Deployment from URL failed with status 400 (Bad Request)", error_msg) |
| 680 | + self.assertIn("source URL is not publicly accessible", error_msg) |
| 681 | + self.assertIn("SAS token has expired", error_msg) |
| 682 | + self.assertIn("artifact type does not match", error_msg) |
| 683 | + |
| 684 | + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') |
| 685 | + def test_400_with_response_body_includes_details(self, send_raw_request_mock): |
| 686 | + """HTTP 400 with a response body should include the details in the error.""" |
| 687 | + from azure.cli.core.azclierror import HTTPError |
| 688 | + cli_ctx = _get_test_cmd().cli_ctx |
| 689 | + |
| 690 | + response = mock.MagicMock() |
| 691 | + response.status_code = 400 |
| 692 | + response.reason = "Bad Request" |
| 693 | + response.text = "Invalid package URI" |
| 694 | + send_raw_request_mock.side_effect = HTTPError("Bad Request(Invalid package URI)", response) |
| 695 | + |
| 696 | + with self.assertRaises(CLIError) as ctx: |
| 697 | + _send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}') |
| 698 | + |
| 699 | + error_msg = str(ctx.exception) |
| 700 | + self.assertIn("Deployment from URL failed with status 400", error_msg) |
| 701 | + self.assertIn("Invalid package URI", error_msg) |
| 702 | + |
| 703 | + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') |
| 704 | + def test_404_gives_not_found_error(self, send_raw_request_mock): |
| 705 | + """HTTP 404 should raise ResourceNotFoundError with guidance.""" |
| 706 | + from azure.cli.core.azclierror import HTTPError |
| 707 | + cli_ctx = _get_test_cmd().cli_ctx |
| 708 | + |
| 709 | + response = mock.MagicMock() |
| 710 | + response.status_code = 404 |
| 711 | + response.reason = "Not Found" |
| 712 | + response.text = "" |
| 713 | + send_raw_request_mock.side_effect = HTTPError("Not Found", response) |
| 714 | + |
| 715 | + with self.assertRaises(ResourceNotFoundError) as ctx: |
| 716 | + _send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}') |
| 717 | + |
| 718 | + error_msg = str(ctx.exception) |
| 719 | + self.assertIn("Deployment from URL failed with status 404", error_msg) |
| 720 | + self.assertIn("app or OneDeploy endpoint could not be found", error_msg) |
| 721 | + |
| 722 | + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') |
| 723 | + def test_409_gives_conflict_error(self, send_raw_request_mock): |
| 724 | + """HTTP 409 should raise ValidationError about in-progress deployment.""" |
| 725 | + from azure.cli.core.azclierror import HTTPError |
| 726 | + cli_ctx = _get_test_cmd().cli_ctx |
| 727 | + |
| 728 | + response = mock.MagicMock() |
| 729 | + response.status_code = 409 |
| 730 | + response.reason = "Conflict" |
| 731 | + response.text = "" |
| 732 | + send_raw_request_mock.side_effect = HTTPError("Conflict", response) |
| 733 | + |
| 734 | + with self.assertRaises(ValidationError) as ctx: |
| 735 | + _send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}') |
| 736 | + |
| 737 | + error_msg = str(ctx.exception) |
| 738 | + self.assertIn("Deployment from URL failed with status 409", error_msg) |
| 739 | + self.assertIn("Another deployment is currently in progress", error_msg) |
| 740 | + |
| 741 | + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') |
| 742 | + def test_unhandled_error_reraises(self, send_raw_request_mock): |
| 743 | + """Unhandled HTTP errors (e.g., 500) should re-raise without modification.""" |
| 744 | + from azure.cli.core.azclierror import HTTPError |
| 745 | + cli_ctx = _get_test_cmd().cli_ctx |
| 746 | + |
| 747 | + response = mock.MagicMock() |
| 748 | + response.status_code = 500 |
| 749 | + response.reason = "Internal Server Error" |
| 750 | + response.text = "Internal Server Error" |
| 751 | + send_raw_request_mock.side_effect = HTTPError("Internal Server Error", response) |
| 752 | + |
| 753 | + with self.assertRaises(HTTPError): |
| 754 | + _send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}') |
| 755 | + |
| 756 | + |
642 | 757 | class FakedResponse: # pylint: disable=too-few-public-methods |
643 | 758 | def __init__(self, status_code): |
644 | 759 | self.status_code = status_code |
|
0 commit comments