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