From e4e58f777d5cc0eddf0e950396399efe8a3998bc Mon Sep 17 00:00:00 2001 From: Bert Blommers Date: Tue, 23 Nov 2021 08:57:15 -0100 Subject: [PATCH] Feature - Budgets service (#4619) --- IMPLEMENTATION_COVERAGE.md | 31 +++- docs/docs/services/budgets.rst | 60 +++++++ moto/__init__.py | 4 +- moto/backend_index.py | 1 + moto/budgets/__init__.py | 4 + moto/budgets/exceptions.py | 29 ++++ moto/budgets/models.py | 126 +++++++++++++++ moto/budgets/responses.py | 66 ++++++++ moto/budgets/urls.py | 10 ++ moto/server.py | 5 +- tests/test_budgets/__init__.py | 0 tests/test_budgets/test_budgets.py | 176 ++++++++++++++++++++ tests/test_budgets/test_notifications.py | 197 +++++++++++++++++++++++ tests/test_budgets/test_server.py | 16 ++ 14 files changed, 720 insertions(+), 5 deletions(-) create mode 100644 docs/docs/services/budgets.rst create mode 100644 moto/budgets/__init__.py create mode 100644 moto/budgets/exceptions.py create mode 100644 moto/budgets/models.py create mode 100644 moto/budgets/responses.py create mode 100644 moto/budgets/urls.py create mode 100644 tests/test_budgets/__init__.py create mode 100644 tests/test_budgets/test_budgets.py create mode 100644 tests/test_budgets/test_notifications.py create mode 100644 tests/test_budgets/test_server.py diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 86d6fcb89..e5ebf6935 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -299,6 +299,34 @@ - [ ] update_scheduling_policy +## budgets +
+31% implemented + +- [X] create_budget +- [ ] create_budget_action +- [X] create_notification +- [ ] create_subscriber +- [X] delete_budget +- [ ] delete_budget_action +- [X] delete_notification +- [ ] delete_subscriber +- [X] describe_budget +- [ ] describe_budget_action +- [ ] describe_budget_action_histories +- [ ] describe_budget_actions_for_account +- [ ] describe_budget_actions_for_budget +- [ ] describe_budget_performance_history +- [X] describe_budgets +- [X] describe_notifications_for_budget +- [ ] describe_subscribers_for_notification +- [ ] execute_budget_action +- [ ] update_budget +- [ ] update_budget_action +- [ ] update_notification +- [ ] update_subscriber +
+ ## cloudformation
27% implemented @@ -4675,7 +4703,6 @@ - autoscaling-plans - backup - braket -- budgets - ce - chime - chime-sdk-identity @@ -4865,4 +4892,4 @@ - workmailmessageflow - workspaces - xray -
+ \ No newline at end of file diff --git a/docs/docs/services/budgets.rst b/docs/docs/services/budgets.rst new file mode 100644 index 000000000..d89cd9125 --- /dev/null +++ b/docs/docs/services/budgets.rst @@ -0,0 +1,60 @@ +.. _implementedservice_budgets: + +.. |start-h3| raw:: html + +

+ +.. |end-h3| raw:: html + +

+ +======= +budgets +======= + +.. autoclass:: moto.budgets.models.BudgetsBackend + +|start-h3| Example usage |end-h3| + +.. sourcecode:: python + + @mock_budgets + def test_budgets_behaviour: + boto3.client("budgets") + ... + + + +|start-h3| Implemented features for this service |end-h3| + +- [X] create_budget +- [ ] create_budget_action +- [X] create_notification +- [ ] create_subscriber +- [X] delete_budget +- [ ] delete_budget_action +- [X] delete_notification +- [ ] delete_subscriber +- [X] describe_budget +- [ ] describe_budget_action +- [ ] describe_budget_action_histories +- [ ] describe_budget_actions_for_account +- [ ] describe_budget_actions_for_budget +- [ ] describe_budget_performance_history +- [X] describe_budgets + + Pagination is not yet implemented + + +- [X] describe_notifications_for_budget + + Pagination has not yet been implemented + + +- [ ] describe_subscribers_for_notification +- [ ] execute_budget_action +- [ ] update_budget +- [ ] update_budget_action +- [ ] update_notification +- [ ] update_subscriber + diff --git a/moto/__init__.py b/moto/__init__.py index b4e7527a0..2b8e1c622 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -38,7 +38,7 @@ mock_lambda = lazy_load( ) mock_lambda_deprecated = lazy_load(".awslambda", "mock_lambda_deprecated") mock_batch = lazy_load(".batch", "mock_batch") -mock_batch = lazy_load(".batch", "mock_batch") +mock_budgets = lazy_load(".budgets", "mock_budgets") mock_cloudformation = lazy_load(".cloudformation", "mock_cloudformation") mock_cloudformation_deprecated = lazy_load( ".cloudformation", "mock_cloudformation_deprecated" @@ -169,7 +169,7 @@ mock_mediastoredata = lazy_load( ) mock_efs = lazy_load(".efs", "mock_efs") mock_wafv2 = lazy_load(".wafv2", "mock_wafv2") -mock_sdb = lazy_load(".sdb", "mock_sdb", boto3_name="sdb") +mock_sdb = lazy_load(".sdb", "mock_sdb") class MockAll(ContextDecorator): diff --git a/moto/backend_index.py b/moto/backend_index.py index b1f3ce537..eaf0c23f6 100644 --- a/moto/backend_index.py +++ b/moto/backend_index.py @@ -11,6 +11,7 @@ backend_url_patterns = [ ("athena", re.compile("https?://athena\\.(.+)\\.amazonaws\\.com")), ("autoscaling", re.compile("https?://autoscaling\\.(.+)\\.amazonaws\\.com")), ("batch", re.compile("https?://batch\\.(.+)\\.amazonaws.com")), + ("budgets", re.compile("https?://budgets\\.amazonaws\\.com")), ("cloudformation", re.compile("https?://cloudformation\\.(.+)\\.amazonaws\\.com")), ("cloudtrail", re.compile("https?://cloudtrail\\.(.+)\\.amazonaws\\.com")), ("cloudwatch", re.compile("https?://monitoring\\.(.+)\\.amazonaws.com")), diff --git a/moto/budgets/__init__.py b/moto/budgets/__init__.py new file mode 100644 index 000000000..90420dcbc --- /dev/null +++ b/moto/budgets/__init__.py @@ -0,0 +1,4 @@ +from .models import budgets_backend + +budgets_backends = {"global": budgets_backend} +mock_budgets = budgets_backend.decorator diff --git a/moto/budgets/exceptions.py b/moto/budgets/exceptions.py new file mode 100644 index 000000000..e16859e14 --- /dev/null +++ b/moto/budgets/exceptions.py @@ -0,0 +1,29 @@ +"""Exceptions raised by the budgets service.""" +from moto.core.exceptions import JsonRESTError + + +class DuplicateRecordException(JsonRESTError): + code = 400 + + def __init__(self, record_type, record_name): + super().__init__( + __class__.__name__, + f"Error creating {record_type}: {record_name} - the {record_type} already exists.", + ) + + +class NotFoundException(JsonRESTError): + code = 400 + + def __init__(self, message): + super().__init__(__class__.__name__, message) + + +class BudgetMissingLimit(JsonRESTError): + code = 400 + + def __init__(self): + super().__init__( + "InvalidParameterException", + "Unable to create/update budget - please provide one of the followings: Budget Limit/ Planned Budget Limit/ Auto Adjust Data", + ) diff --git a/moto/budgets/models.py b/moto/budgets/models.py new file mode 100644 index 000000000..6c0ee51bd --- /dev/null +++ b/moto/budgets/models.py @@ -0,0 +1,126 @@ +from collections import defaultdict +from copy import deepcopy +from datetime import datetime +from moto.core import BaseBackend, BaseModel +from moto.core.utils import unix_time + +from .exceptions import BudgetMissingLimit, DuplicateRecordException, NotFoundException + + +class Notification(BaseModel): + def __init__(self, details, subscribers): + self.details = details + self.subscribers = subscribers + + +class Budget(BaseModel): + def __init__(self, budget, notifications): + if "BudgetLimit" not in budget and "PlannedBudgetLimits" not in budget: + raise BudgetMissingLimit() + # Storing the budget as a Dict for now - if we need more control, we can always read/write it back + self.budget = budget + self.notifications = [ + Notification(details=x["Notification"], subscribers=x["Subscribers"]) + for x in notifications + ] + self.budget["LastUpdatedTime"] = unix_time() + if "TimePeriod" not in self.budget: + first_day_of_month = datetime.now().replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + self.budget["TimePeriod"] = { + "Start": unix_time(first_day_of_month), + "End": 3706473600, # "2087-06-15T00:00:00+00:00" + } + + def to_dict(self): + cp = deepcopy(self.budget) + if "CalculatedSpend" not in cp: + cp["CalculatedSpend"] = { + "ActualSpend": {"Amount": "0", "Unit": "USD"}, + "ForecastedSpend": {"Amount": "0", "Unit": "USD"}, + } + if self.budget["BudgetType"] == "COST" and "CostTypes" not in cp: + cp["CostTypes"] = { + "IncludeCredit": True, + "IncludeDiscount": True, + "IncludeOtherSubscription": True, + "IncludeRecurring": True, + "IncludeRefund": True, + "IncludeSubscription": True, + "IncludeSupport": True, + "IncludeTax": True, + "IncludeUpfront": True, + "UseAmortized": False, + "UseBlended": False, + } + return cp + + def add_notification(self, details, subscribers): + self.notifications.append(Notification(details, subscribers)) + + def delete_notification(self, details): + self.notifications = [n for n in self.notifications if n.details != details] + + def get_notifications(self): + return [n.details for n in self.notifications] + + +class BudgetsBackend(BaseBackend): + """Implementation of Budgets APIs.""" + + def __init__(self): + # {"account_id": {"budget_name": Budget}} + self.budgets = defaultdict(dict) + + def create_budget(self, account_id, budget, notifications): + budget_name = budget["BudgetName"] + if budget_name in self.budgets[account_id]: + raise DuplicateRecordException( + record_type="budget", record_name=budget_name + ) + self.budgets[account_id][budget_name] = Budget(budget, notifications) + + def describe_budget(self, account_id, budget_name): + if budget_name not in self.budgets[account_id]: + raise NotFoundException( + f"Unable to get budget: {budget_name} - the budget doesn't exist." + ) + return self.budgets[account_id][budget_name].to_dict() + + def describe_budgets(self, account_id): + """ + Pagination is not yet implemented + """ + return [budget.to_dict() for budget in self.budgets[account_id].values()] + + def delete_budget(self, account_id, budget_name): + if budget_name not in self.budgets[account_id]: + msg = f"Unable to delete budget: {budget_name} - the budget doesn't exist. Try creating it first. " + raise NotFoundException(msg) + self.budgets[account_id].pop(budget_name) + + def create_notification(self, account_id, budget_name, notification, subscribers): + if budget_name not in self.budgets[account_id]: + raise NotFoundException( + "Unable to create notification - the budget doesn't exist." + ) + self.budgets[account_id][budget_name].add_notification( + details=notification, subscribers=subscribers + ) + + def delete_notification(self, account_id, budget_name, notification): + if budget_name not in self.budgets[account_id]: + raise NotFoundException( + "Unable to delete notification - the budget doesn't exist." + ) + self.budgets[account_id][budget_name].delete_notification(details=notification) + + def describe_notifications_for_budget(self, account_id, budget_name): + """ + Pagination has not yet been implemented + """ + return self.budgets[account_id][budget_name].get_notifications() + + +budgets_backend = BudgetsBackend() diff --git a/moto/budgets/responses.py b/moto/budgets/responses.py new file mode 100644 index 000000000..ca650a13e --- /dev/null +++ b/moto/budgets/responses.py @@ -0,0 +1,66 @@ +import json + +from moto.core.responses import BaseResponse +from .models import budgets_backend + + +class BudgetsResponse(BaseResponse): + def create_budget(self): + account_id = self._get_param("AccountId") + budget = self._get_param("Budget") + notifications = self._get_param("NotificationsWithSubscribers", []) + budgets_backend.create_budget( + account_id=account_id, budget=budget, notifications=notifications + ) + return json.dumps(dict()) + + def describe_budget(self): + account_id = self._get_param("AccountId") + budget_name = self._get_param("BudgetName") + budget = budgets_backend.describe_budget( + account_id=account_id, budget_name=budget_name + ) + return json.dumps(dict(Budget=budget)) + + def describe_budgets(self): + account_id = self._get_param("AccountId") + budgets = budgets_backend.describe_budgets(account_id=account_id) + return json.dumps(dict(Budgets=budgets, nextToken=None)) + + def delete_budget(self): + account_id = self._get_param("AccountId") + budget_name = self._get_param("BudgetName") + budgets_backend.delete_budget( + account_id=account_id, budget_name=budget_name, + ) + return json.dumps(dict()) + + def create_notification(self): + account_id = self._get_param("AccountId") + budget_name = self._get_param("BudgetName") + notification = self._get_param("Notification") + subscribers = self._get_param("Subscribers") + budgets_backend.create_notification( + account_id=account_id, + budget_name=budget_name, + notification=notification, + subscribers=subscribers, + ) + return json.dumps(dict()) + + def delete_notification(self): + account_id = self._get_param("AccountId") + budget_name = self._get_param("BudgetName") + notification = self._get_param("Notification") + budgets_backend.delete_notification( + account_id=account_id, budget_name=budget_name, notification=notification, + ) + return json.dumps(dict()) + + def describe_notifications_for_budget(self): + account_id = self._get_param("AccountId") + budget_name = self._get_param("BudgetName") + notifications = budgets_backend.describe_notifications_for_budget( + account_id=account_id, budget_name=budget_name, + ) + return json.dumps(dict(Notifications=notifications, NextToken=None)) diff --git a/moto/budgets/urls.py b/moto/budgets/urls.py new file mode 100644 index 000000000..49840b4d1 --- /dev/null +++ b/moto/budgets/urls.py @@ -0,0 +1,10 @@ +from .responses import BudgetsResponse + +url_bases = [ + r"https?://budgets\.amazonaws\.com", +] + + +url_paths = { + "{0}/$": BudgetsResponse.dispatch, +} diff --git a/moto/server.py b/moto/server.py index 2e87ac4bc..65c4e5c35 100644 --- a/moto/server.py +++ b/moto/server.py @@ -119,7 +119,10 @@ class DomainDispatcherApplication(object): # S3 is the last resort when the target is also unknown service, region = DEFAULT_SERVICE_REGION - if service == "mediastore" and not target: + if service == "budgets": + # Budgets is global + host = f"{service}.amazonaws.com" + elif service == "mediastore" and not target: # All MediaStore API calls have a target header # If no target is set, assume we're trying to reach the mediastore-data service host = "data.{service}.{region}.amazonaws.com".format( diff --git a/tests/test_budgets/__init__.py b/tests/test_budgets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_budgets/test_budgets.py b/tests/test_budgets/test_budgets.py new file mode 100644 index 000000000..578a72988 --- /dev/null +++ b/tests/test_budgets/test_budgets.py @@ -0,0 +1,176 @@ +import boto3 +import pytest + +from botocore.exceptions import ClientError +import sure # noqa # pylint: disable=unused-import +from datetime import datetime +from moto import mock_budgets +from moto.core import ACCOUNT_ID + + +@mock_budgets +def test_create_and_describe_budget_minimal_params(): + client = boto3.client("budgets", region_name="us-east-1") + resp = client.create_budget( + AccountId=ACCOUNT_ID, + Budget={ + "BudgetLimit": {"Amount": "10", "Unit": "USD"}, + "BudgetName": "testbudget", + "TimeUnit": "DAILY", + "BudgetType": "COST", + }, + ) + resp["ResponseMetadata"]["HTTPStatusCode"].should.equal(200) + + budget = client.describe_budget(AccountId=ACCOUNT_ID, BudgetName="testbudget")[ + "Budget" + ] + budget.should.have.key("BudgetLimit") + budget["BudgetLimit"].should.have.key("Amount") + budget["BudgetLimit"]["Amount"].should.equal("10") + budget["BudgetLimit"].should.have.key("Unit") + budget["BudgetLimit"]["Unit"].should.equal("USD") + budget.should.have.key("BudgetName").equals("testbudget") + budget.should.have.key("TimeUnit").equals("DAILY") + budget.should.have.key("BudgetType").equals("COST") + budget.should.have.key("CalculatedSpend") + budget["CalculatedSpend"].should.have.key("ActualSpend") + budget["CalculatedSpend"]["ActualSpend"].should.equal( + {"Amount": "0", "Unit": "USD"} + ) + budget["CalculatedSpend"].should.have.key("ForecastedSpend") + budget["CalculatedSpend"]["ForecastedSpend"].should.equal( + {"Amount": "0", "Unit": "USD"} + ) + budget.should.have.key("CostTypes") + budget["CostTypes"].should.have.key("IncludeCredit").equals(True) + budget["CostTypes"].should.have.key("IncludeDiscount").equals(True) + budget["CostTypes"].should.have.key("IncludeOtherSubscription").equals(True) + budget["CostTypes"].should.have.key("IncludeRecurring").equals(True) + budget["CostTypes"].should.have.key("IncludeRefund").equals(True) + budget["CostTypes"].should.have.key("IncludeSubscription").equals(True) + budget["CostTypes"].should.have.key("IncludeSupport").equals(True) + budget["CostTypes"].should.have.key("IncludeTax").equals(True) + budget["CostTypes"].should.have.key("IncludeUpfront").equals(True) + budget["CostTypes"].should.have.key("UseAmortized").equals(False) + budget["CostTypes"].should.have.key("UseBlended").equals(False) + budget.should.have.key("LastUpdatedTime").should.be.a(datetime) + budget.should.have.key("TimePeriod") + budget["TimePeriod"].should.have.key("Start") + budget["TimePeriod"]["Start"].should.be.a(datetime) + budget["TimePeriod"].should.have.key("End") + budget["TimePeriod"]["End"].should.be.a(datetime) + budget.should.have.key("TimeUnit").equals("DAILY") + + +@mock_budgets +def test_create_existing_budget(): + client = boto3.client("budgets", region_name="us-east-1") + client.create_budget( + AccountId=ACCOUNT_ID, + Budget={ + "BudgetLimit": {"Amount": "10", "Unit": "USD"}, + "BudgetName": "testb", + "TimeUnit": "DAILY", + "BudgetType": "COST", + }, + ) + + with pytest.raises(ClientError) as exc: + client.create_budget( + AccountId=ACCOUNT_ID, + Budget={ + "BudgetLimit": {"Amount": "10", "Unit": "USD"}, + "BudgetName": "testb", + "TimeUnit": "DAILY", + "BudgetType": "COST", + }, + ) + err = exc.value.response["Error"] + err["Code"].should.equal("DuplicateRecordException") + err["Message"].should.equal( + "Error creating budget: testb - the budget already exists." + ) + + +@mock_budgets +def test_create_budget_without_limit_param(): + client = boto3.client("budgets", region_name="us-east-1") + with pytest.raises(ClientError) as exc: + client.create_budget( + AccountId=ACCOUNT_ID, + Budget={"BudgetName": "testb", "TimeUnit": "DAILY", "BudgetType": "COST"}, + ) + err = exc.value.response["Error"] + err["Code"].should.equal("InvalidParameterException") + err["Message"].should.equal( + "Unable to create/update budget - please provide one of the followings: Budget Limit/ Planned Budget Limit/ Auto Adjust Data" + ) + + +@mock_budgets +def test_describe_unknown_budget(): + client = boto3.client("budgets", region_name="us-east-1") + with pytest.raises(ClientError) as exc: + client.describe_budget(AccountId=ACCOUNT_ID, BudgetName="unknown") + err = exc.value.response["Error"] + err["Code"].should.equal("NotFoundException") + err["Message"].should.equal( + "Unable to get budget: unknown - the budget doesn't exist." + ) + + +@mock_budgets +def test_describe_no_budgets(): + client = boto3.client("budgets", region_name="us-east-1") + resp = client.describe_budgets(AccountId=ACCOUNT_ID) + resp.should.have.key("Budgets").equals([]) + + +@mock_budgets +def test_create_and_describe_all_budgets(): + client = boto3.client("budgets", region_name="us-east-1") + client.create_budget( + AccountId=ACCOUNT_ID, + Budget={ + "BudgetLimit": {"Amount": "10", "Unit": "USD"}, + "BudgetName": "testbudget", + "TimeUnit": "DAILY", + "BudgetType": "COST", + }, + ) + + res = client.describe_budgets(AccountId=ACCOUNT_ID) + res["Budgets"].should.have.length_of(1) + + +@mock_budgets +def test_delete_budget(): + client = boto3.client("budgets", region_name="us-east-1") + client.create_budget( + AccountId=ACCOUNT_ID, + Budget={ + "BudgetLimit": {"Amount": "10", "Unit": "USD"}, + "BudgetName": "b1", + "TimeUnit": "DAILY", + "BudgetType": "COST", + }, + ) + + resp = client.delete_budget(AccountId=ACCOUNT_ID, BudgetName="b1") + resp["ResponseMetadata"]["HTTPStatusCode"].should.equal(200) + + res = client.describe_budgets(AccountId=ACCOUNT_ID) + res["Budgets"].should.have.length_of(0) + + +@mock_budgets +def test_delete_unknown_budget(): + client = boto3.client("budgets", region_name="us-east-1") + with pytest.raises(ClientError) as exc: + client.delete_budget(AccountId=ACCOUNT_ID, BudgetName="unknown") + err = exc.value.response["Error"] + err["Code"].should.equal("NotFoundException") + err["Message"].should.equal( + "Unable to delete budget: unknown - the budget doesn't exist. Try creating it first. " + ) diff --git a/tests/test_budgets/test_notifications.py b/tests/test_budgets/test_notifications.py new file mode 100644 index 000000000..e836e5f48 --- /dev/null +++ b/tests/test_budgets/test_notifications.py @@ -0,0 +1,197 @@ +import boto3 +import pytest + +from botocore.exceptions import ClientError +import sure # noqa # pylint: disable=unused-import +from moto import mock_budgets +from moto.core import ACCOUNT_ID + + +@mock_budgets +def test_create_and_describe_notification(): + client = boto3.client("budgets", region_name="us-east-1") + client.create_budget( + AccountId=ACCOUNT_ID, + Budget={ + "BudgetLimit": {"Amount": "10", "Unit": "USD"}, + "BudgetName": "testbudget", + "TimeUnit": "DAILY", + "BudgetType": "COST", + }, + NotificationsWithSubscribers=[ + { + "Notification": { + "NotificationType": "ACTUAL", + "ComparisonOperator": "EQUAL_TO", + "Threshold": 123.0, + "ThresholdType": "ABSOLUTE_VALUE", + "NotificationState": "ALARM", + }, + "Subscribers": [ + {"SubscriptionType": "EMAIL", "Address": "admin@moto.com"}, + ], + } + ], + ) + + res = client.describe_notifications_for_budget( + AccountId=ACCOUNT_ID, BudgetName="testbudget" + ) + res.should.have.key("Notifications").length_of(1) + notification = res["Notifications"][0] + notification.should.have.key("NotificationType").should.equal("ACTUAL") + notification.should.have.key("ComparisonOperator").should.equal("EQUAL_TO") + notification.should.have.key("Threshold").should.equal(123) + notification.should.have.key("ThresholdType").should.equal("ABSOLUTE_VALUE") + notification.should.have.key("NotificationState").should.equal("ALARM") + + +@mock_budgets +def test_create_notification(): + client = boto3.client("budgets", region_name="us-east-1") + client.create_budget( + AccountId=ACCOUNT_ID, + Budget={ + "BudgetLimit": {"Amount": "10", "Unit": "USD"}, + "BudgetName": "testbudget", + "TimeUnit": "DAILY", + "BudgetType": "COST", + }, + NotificationsWithSubscribers=[ + { + "Notification": { + "NotificationType": "ACTUAL", + "ComparisonOperator": "EQUAL_TO", + "Threshold": 123.0, + "ThresholdType": "ABSOLUTE_VALUE", + "NotificationState": "ALARM", + }, + "Subscribers": [ + {"SubscriptionType": "EMAIL", "Address": "admin@moto.com"}, + ], + } + ], + ) + + res = client.create_notification( + AccountId=ACCOUNT_ID, + BudgetName="testbudget", + Notification={ + "NotificationType": "ACTUAL", + "ComparisonOperator": "GREATER_THAN", + "Threshold": 0.0, + "ThresholdType": "ABSOLUTE_VALUE", + "NotificationState": "OK", + }, + Subscribers=[{"SubscriptionType": "SNS", "Address": "arn:sns:topic:mytopic"}], + ) + res["ResponseMetadata"]["HTTPStatusCode"].should.equal(200) + + res = client.describe_notifications_for_budget( + AccountId=ACCOUNT_ID, BudgetName="testbudget" + ) + res.should.have.key("Notifications").length_of(2) + n_1 = res["Notifications"][0] + n_1.should.have.key("NotificationType").should.equal("ACTUAL") + n_1.should.have.key("ComparisonOperator").should.equal("EQUAL_TO") + n_1.should.have.key("Threshold").should.equal(123) + n_1.should.have.key("ThresholdType").should.equal("ABSOLUTE_VALUE") + n_1.should.have.key("NotificationState").should.equal("ALARM") + n_2 = res["Notifications"][1] + n_2.should.have.key("NotificationType").should.equal("ACTUAL") + n_2.should.have.key("ComparisonOperator").should.equal("GREATER_THAN") + n_2.should.have.key("Threshold").should.equal(0) + n_2.should.have.key("ThresholdType").should.equal("ABSOLUTE_VALUE") + n_2.should.have.key("NotificationState").should.equal("OK") + + +@mock_budgets +def test_create_notification_unknown_budget(): + client = boto3.client("budgets", region_name="us-east-1") + + with pytest.raises(ClientError) as exc: + client.create_notification( + AccountId=ACCOUNT_ID, + BudgetName="testbudget", + Notification={ + "NotificationType": "FORECASTED", # doesn't exist + "ComparisonOperator": "EQUAL_TO", + "Threshold": 123.0, + "ThresholdType": "ABSOLUTE_VALUE", + "NotificationState": "ALARM", + }, + Subscribers=[{"SubscriptionType": "EMAIL", "Address": "admin@moto.com"}], + ) + err = exc.value.response["Error"] + err["Code"].should.equal("NotFoundException") + err["Message"].should.equal( + "Unable to create notification - the budget doesn't exist." + ) + + +@mock_budgets +def test_delete_notification(): + client = boto3.client("budgets", region_name="us-east-1") + client.create_budget( + AccountId=ACCOUNT_ID, + Budget={ + "BudgetLimit": {"Amount": "10", "Unit": "USD"}, + "BudgetName": "testbudget", + "TimeUnit": "DAILY", + "BudgetType": "COST", + }, + NotificationsWithSubscribers=[ + { + "Notification": { + "NotificationType": "ACTUAL", + "ComparisonOperator": "EQUAL_TO", + "Threshold": 123.0, + "ThresholdType": "ABSOLUTE_VALUE", + "NotificationState": "ALARM", + }, + "Subscribers": [ + {"SubscriptionType": "EMAIL", "Address": "admin@moto.com"}, + ], + } + ], + ) + + client.delete_notification( + AccountId=ACCOUNT_ID, + BudgetName="testbudget", + Notification={ + "NotificationType": "ACTUAL", + "ComparisonOperator": "EQUAL_TO", + "Threshold": 123.0, + "ThresholdType": "ABSOLUTE_VALUE", + "NotificationState": "ALARM", + }, + ) + + res = client.describe_notifications_for_budget( + AccountId=ACCOUNT_ID, BudgetName="testbudget" + ) + res.should.have.key("Notifications").length_of(0) + + +@mock_budgets +def test_delete_notification_unknown_budget(): + client = boto3.client("budgets", region_name="us-east-1") + + with pytest.raises(ClientError) as exc: + client.delete_notification( + AccountId=ACCOUNT_ID, + BudgetName="testbudget", + Notification={ + "NotificationType": "FORECASTED", + "ComparisonOperator": "EQUAL_TO", + "Threshold": 123.0, + "ThresholdType": "ABSOLUTE_VALUE", + "NotificationState": "ALARM", + }, + ) + err = exc.value.response["Error"] + err["Code"].should.equal("NotFoundException") + err["Message"].should.equal( + "Unable to delete notification - the budget doesn't exist." + ) diff --git a/tests/test_budgets/test_server.py b/tests/test_budgets/test_server.py new file mode 100644 index 000000000..3bfec7297 --- /dev/null +++ b/tests/test_budgets/test_server.py @@ -0,0 +1,16 @@ +import json +import sure # noqa # pylint: disable=unused-import + +import moto.server as server +from moto import mock_budgets + + +@mock_budgets +def test_budgets_describe_budgets(): + backend = server.create_backend_app("budgets") + test_client = backend.test_client() + + headers = {"X-Amz-Target": "AWSBudgetServiceGateway.DescribeBudgets"} + resp = test_client.post("/", headers=headers, json={}) + resp.status_code.should.equal(200) + json.loads(resp.data).should.equal({"Budgets": [], "nextToken": None})