Repository: getmoto/moto
Pull request #7553 (2024-04-02 07:53:46)

DynamoDB: duplicate error should be thrown for batch writer
Closes https://github.com/getmoto/moto/issues/7538


Patch:
diff --git a/moto/dynamodb/models/table.py b/moto/dynamodb/models/table.py
index 8e24760bb..6f9c4f933 100644
--- a/moto/dynamodb/models/table.py
+++ b/moto/dynamodb/models/table.py
@@ -698,19 +698,27 @@ class Table(CloudFormationModel):
                         f"Range Key comparison but no range key found for index: {index_name}"
                     )
 
+            actual_hash_attr = index_hash_key["AttributeName"]
+            actual_range_attr = (
+                index_range_key["AttributeName"] if index_range_key else None
+            )
+
             possible_results = []
             for item in self.all_items():
                 if not isinstance(item, Item):
                     continue
-                item_hash_key = item.attrs.get(index_hash_key["AttributeName"])
-                if index_range_key is None:
+                item_hash_key = item.attrs.get(actual_hash_attr)
+                if actual_range_attr is None:
                     if item_hash_key and item_hash_key == hash_key:
                         possible_results.append(item)
                 else:
-                    item_range_key = item.attrs.get(index_range_key["AttributeName"])
+                    item_range_key = item.attrs.get(actual_range_attr)
                     if item_hash_key and item_hash_key == hash_key and item_range_key:
                         possible_results.append(item)
         else:
+            actual_hash_attr = self.hash_key_attr
+            actual_range_attr = self.range_key_attr
+
             possible_results = [
                 item
                 for item in self.all_items()
@@ -719,15 +727,15 @@ class Table(CloudFormationModel):
 
         # SORT
         if index_name:
-            if index_range_key:
+            if actual_range_attr is not None:
                 # Convert to float if necessary to ensure proper ordering
                 def conv(x: DynamoType) -> Any:
                     return float(x.value) if x.type == "N" else x.value
 
                 possible_results.sort(
                     key=lambda item: (  # type: ignore
-                        conv(item.attrs[index_range_key["AttributeName"]])  # type: ignore
-                        if item.attrs.get(index_range_key["AttributeName"])
+                        conv(item.attrs[actual_range_attr])  # type: ignore
+                        if item.attrs.get(actual_range_attr)
                         else None
                     )
                 )
@@ -747,7 +755,13 @@ class Table(CloudFormationModel):
             # Cycle through the previous page of results
             # When we encounter our start key, we know we've reached the end of the previous page
             if processing_previous_page:
-                if self._item_comes_before_dct(result, exclusive_start_key):
+                if self._item_comes_before_dct(
+                    result,
+                    exclusive_start_key,
+                    actual_hash_attr,
+                    actual_range_attr,
+                    scan_index_forward,
+                ):
                     continue
                 else:
                     processing_previous_page = False
@@ -873,8 +887,32 @@ class Table(CloudFormationModel):
                     "Consistent reads are not supported on global secondary indexes"
                 )
 
+            try:
+                index_hash_key = [
+                    key for key in index.schema if key["KeyType"] == "HASH"
+                ][0]
+            except IndexError:
+                raise MockValidationException(
+                    f"Missing Hash Key. KeySchema: {index.name}"
+                )
+
+            try:
+                index_range_key = [
+                    key for key in index.schema if key["KeyType"] == "RANGE"
+                ][0]
+            except IndexError:
+                index_range_key = None
+
+            actual_hash_attr = index_hash_key["AttributeName"]
+            actual_range_attr = (
+                index_range_key["AttributeName"] if index_range_key else None
+            )
+
             items = self.has_idx_items(index_name)
         else:
+            actual_hash_attr = self.hash_key_attr
+            actual_range_attr = self.range_key_attr
+
             items = self.all_items()
 
         last_evaluated_key = None
@@ -883,7 +921,13 @@ class Table(CloudFormationModel):
             # Cycle through the previous page of results
             # When we encounter our start key, we know we've reached the end of the previous page
             if processing_previous_page:
-                if self._item_comes_before_dct(item, exclusive_start_key):
+                if self._item_comes_before_dct(
+                    item,
+                    exclusive_start_key,
+                    actual_hash_attr,
+                    actual_range_attr,
+                    True,
+                ):
                     continue
                 else:
                     processing_previous_page = False
@@ -937,16 +981,31 @@ class Table(CloudFormationModel):
 
         return results, scanned_count, last_evaluated_key
 
-    def _item_comes_before_dct(self, item: Item, dct: Dict[str, Any]) -> bool:
-        hash_key = DynamoType(dct.get(self.hash_key_attr))  # type: ignore[arg-type]
-        range_key = dct.get(self.range_key_attr) if self.range_key_attr else None
-        if range_key is not None:
-            range_key = DynamoType(range_key)
-            if item.hash_key < hash_key:
-                return True
-            else:
-                return item.hash_key <= hash_key and item.range_key <= range_key
-        return item.hash_key <= hash_key
+    def _item_comes_before_dct(
+        self,
+        item: Item,
+        dct: Dict[str, Any],
+        hash_key_attr: str,
+        range_key_attr: Optional[str],
+        scan_index_forward: bool,
+    ) -> bool:
+        """Does item appear before or at dct relative to sort options?"""
+        dict_hash_val = DynamoType(dct.get(hash_key_attr))  # type: ignore[arg-type]
+        item_hash_val = item.attrs[hash_key_attr]
+        if item_hash_val != dict_hash_val:
+            # If hash keys are different, order immediately
+            return bool(item_hash_val < dict_hash_val) == scan_index_forward
+        if range_key_attr is None:
+            # If hash keys match and no range key, items are identical
+            return True
+        # We know hash keys are equal, use range key as tiebreaker
+        dict_range_val = DynamoType(dct.get(range_key_attr))  # type: ignore[arg-type]
+        item_range_val = item.attrs[range_key_attr]
+        if item_range_val == dict_range_val:
+            # If range keys (also) match, items are identical
+            return True
+        # Range keys are different, order accordingly
+        return bool(item_range_val < dict_range_val) == scan_index_forward  # type: ignore[arg-type]
 
     def _get_last_evaluated_key(
         self, last_result: Item, index_name: Optional[str]
diff --git a/moto/dynamodb/responses.py b/moto/dynamodb/responses.py
index 3b50dfc7a..e8e8d1569 100644
--- a/moto/dynamodb/responses.py
+++ b/moto/dynamodb/responses.py
@@ -526,7 +526,12 @@ class DynamoHandler(BaseResponse):
                 elif request_type == "DeleteRequest":
                     keys = request["Key"]
                     delete_requests.append((table_name, keys))
-
+        if self._contains_duplicates(
+            [json.dumps(k[1]) for k in delete_requests]
+        ) or self._contains_duplicates([json.dumps(k[1]) for k in put_requests]):
+            raise MockValidationException(
+                "Provided list of item keys contains duplicates"
+            )
         for table_name, item in put_requests:
             self.dynamodb_backend.put_item(table_name, item)
         for table_name, keys in delete_requests:
@@ -730,7 +735,7 @@ class DynamoHandler(BaseResponse):
         index_name = self.body.get("IndexName")
         exclusive_start_key = self.body.get("ExclusiveStartKey")
         limit = self.body.get("Limit")
-        scan_index_forward = self.body.get("ScanIndexForward")
+        scan_index_forward = self.body.get("ScanIndexForward", True)
         consistent_read = self.body.get("ConsistentRead", False)
 
         items, scanned_count, last_evaluated_key = self.dynamodb_backend.query(
diff --git a/moto/secretsmanager/models.py b/moto/secretsmanager/models.py
index 036501145..6f38516aa 100644
--- a/moto/secretsmanager/models.py
+++ b/moto/secretsmanager/models.py
@@ -28,6 +28,8 @@ from .list_secrets.filters import (
 )
 from .utils import get_secret_name_from_partial_arn, random_password, secret_arn
 
+MAX_RESULTS_DEFAULT = 100
+
 
 def filter_primary_region(secret: "FakeSecret", values: List[str]) -> bool:
     if isinstance(secret, FakeSecret):
@@ -470,6 +472,53 @@ class SecretsManagerBackend(BaseBackend):
 
         return response_data
 
+    def batch_get_secret_value(
+        self,
+        secret_id_list: Optional[List[str]] = None,
+        filters: Optional[List[Dict[str, Any]]] = None,
+        max_results: Optional[int] = None,
+        next_token: Optional[str] = None,
+    ) -> Tuple[List[Dict[str, Any]], List[Any], Optional[str]]:
+        secret_list = []
+        errors: List[Any] = []
+        if secret_id_list and filters:
+            raise InvalidParameterException(
+                "Either 'SecretIdList' or 'Filters' must be provided, but not both."
+            )
+
+        if max_results and not filters:
+            raise InvalidParameterException(
+                "'Filters' not specified. 'Filters' must also be specified when 'MaxResults' is provided."
+            )
+
+        if secret_id_list:
+            for secret_id in secret_id_list:
+                # TODO perhaps there should be a check if the secret id is valid identifier
+                # and add an error to the list if not
+                try:
+                    # TODO investigate the behaviour when the secret doesn't exist or has been deleted,
+                    # might need to add an error to the list
+                    secret_list.append(self.get_secret_value(secret_id, "", ""))
+                except (SecretNotFoundException, InvalidRequestException):
+                    pass
+
+        if filters:
+            for secret in self.secrets.values():
+                if _matches(secret, filters):
+                    if isinstance(secret, FakeSecret):
+                        secret_list.append(
+                            self.get_secret_value(secret.secret_id, "", "")
+                        )
+                    elif isinstance(secret, ReplicaSecret):
+                        secret_list.append(
+                            self.get_secret_value(secret.source.secret_id, "", "")
+                        )
+
+        secret_page, new_next_token = self._get_secret_values_page_and_next_token(
+            secret_list, max_results, next_token
+        )
+        return secret_page, errors, new_next_token
+
     def update_secret(
         self,
         secret_id: str,
@@ -850,20 +899,17 @@ class SecretsManagerBackend(BaseBackend):
     def list_secrets(
         self,
         filters: List[Dict[str, Any]],
-        max_results: int = 100,
+        max_results: int = MAX_RESULTS_DEFAULT,
         next_token: Optional[str] = None,
     ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
-        secret_list = []
+        secret_list: List[Dict[str, Any]] = []
         for secret in self.secrets.values():
             if _matches(secret, filters):
                 secret_list.append(secret.to_dict())
 
-        starting_point = int(next_token or 0)
-        ending_point = starting_point + int(max_results or 100)
-        secret_page = secret_list[starting_point:ending_point]
-        new_next_token = str(ending_point) if ending_point < len(secret_list) else None
-
-        return secret_page, new_next_token
+        return self._get_secret_values_page_and_next_token(
+            secret_list, max_results, next_token
+        )
 
     def delete_secret(
         self,
@@ -1094,5 +1140,18 @@ class SecretsManagerBackend(BaseBackend):
         statuses = [replica.config for replica in secret.replicas]
         return secret_id, statuses
 
+    def _get_secret_values_page_and_next_token(
+        self,
+        secret_list: List[Dict[str, Any]],
+        max_results: Optional[int],
+        next_token: Optional[str],
+    ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
+        starting_point = int(next_token or 0)
+        ending_point = starting_point + int(max_results or MAX_RESULTS_DEFAULT)
+        secret_page = secret_list[starting_point:ending_point]
+        new_next_token = str(ending_point) if ending_point < len(secret_list) else None
+
+        return secret_page, new_next_token
+
 
 secretsmanager_backends = BackendDict(SecretsManagerBackend, "secretsmanager")
diff --git a/moto/secretsmanager/responses.py b/moto/secretsmanager/responses.py
index f1b107408..72a7403fa 100644
--- a/moto/secretsmanager/responses.py
+++ b/moto/secretsmanager/responses.py
@@ -49,6 +49,22 @@ class SecretsManagerResponse(BaseResponse):
         )
         return json.dumps(value)
 
+    def batch_get_secret_value(self) -> str:
+        secret_id_list = self._get_param("SecretIdList", if_none=[])
+        filters = self._get_param("Filters", if_none=[])
+        max_results = self._get_int_param("MaxResults")
+        next_token = self._get_param("NextToken")
+
+        secret_values, errors, next_token = self.backend.batch_get_secret_value(
+            secret_id_list=secret_id_list,
+            filters=filters,
+            max_results=max_results,
+            next_token=next_token,
+        )
+        return json.dumps(
+            dict(SecretValues=secret_values, Errors=errors, NextToken=next_token)
+        )
+
     def create_secret(self) -> str:
         name = self._get_param("Name")
         secret_string = self._get_param("SecretString")


Test patch:
diff --git a/tests/test_dynamodb/conftest.py b/tests/test_dynamodb/conftest.py
index 04d975c2b..8c4915154 100644
--- a/tests/test_dynamodb/conftest.py
+++ b/tests/test_dynamodb/conftest.py
@@ -1,9 +1,61 @@
+import os
+
+import boto3
 import pytest
 
+from moto import mock_aws
 from moto.core import DEFAULT_ACCOUNT_ID
 from moto.dynamodb.models import Table
 
 
+# pylint: disable=redefined-outer-name
+@pytest.fixture(scope="function")
+def aws_credentials():
+    """Mocked AWS Credentials for moto."""
+    os.environ["AWS_ACCESS_KEY_ID"] = "testing"
+    os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
+    os.environ["AWS_SECURITY_TOKEN"] = "testing"
+    os.environ["AWS_SESSION_TOKEN"] = "testing"
+    os.environ["AWS_DEFAULT_REGION"] = "us-east-1"
+
+
+@pytest.fixture(scope="function")
+def ddb_client(aws_credentials):
+    with mock_aws():
+        yield boto3.client("dynamodb")
+
+
+@pytest.fixture(scope="function")
+def ddb_resource(aws_credentials):
+    with mock_aws():
+        yield boto3.resource("dynamodb")
+
+
+@pytest.fixture
+def users_table_name():
+    return "users"
+
+
+@pytest.fixture
+def create_user_table(ddb_client, users_table_name):
+    ddb_client.create_table(
+        TableName=users_table_name,
+        KeySchema=[{"AttributeName": "username", "KeyType": "HASH"}],
+        AttributeDefinitions=[{"AttributeName": "username", "AttributeType": "S"}],
+        ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5},
+    )
+    ddb_client.put_item(
+        TableName="users", Item={"username": {"S": "user1"}, "binaryfoo": {"B": b"bar"}}
+    )
+    ddb_client.put_item(
+        TableName="users", Item={"username": {"S": "user2"}, "foo": {"S": "bar"}}
+    )
+    ddb_client.put_item(
+        TableName="users", Item={"username": {"S": "user3"}, "foo": {"S": "bar"}}
+    )
+    return ddb_client
+
+
 @pytest.fixture
 def table():
     return Table(
diff --git a/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py b/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py
index a29f15e09..a8ecd2224 100644
--- a/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py
+++ b/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py
@@ -746,6 +746,35 @@ def test_update_expression_with_trailing_comma():
     )
 
 
+@mock_aws
+def test_batch_items_should_throw_exception_for_duplicate_request(
+    ddb_resource, create_user_table, users_table_name
+):
+    # Setup
+    table = ddb_resource.Table(users_table_name)
+    test_item = {"forum_name": "test_dupe", "subject": "test1"}
+
+    # Execute
+    with pytest.raises(ClientError) as ex:
+        with table.batch_writer() as batch:
+            for i in range(0, 5):
+                batch.put_item(Item=test_item)
+
+    with pytest.raises(ClientError) as ex2:
+        with table.batch_writer() as batch:
+            for i in range(0, 5):
+                batch.delete_item(Key=test_item)
+
+    # Verify
+    err = ex.value.response["Error"]
+    assert err["Code"] == "ValidationException"
+    assert err["Message"] == "Provided list of item keys contains duplicates"
+
+    err2 = ex2.value.response["Error"]
+    assert err2["Code"] == "ValidationException"
+    assert err2["Message"] == "Provided list of item keys contains duplicates"
+
+
 @mock_aws
 def test_batch_put_item_with_empty_value():
     ddb = boto3.resource("dynamodb", region_name="us-east-1")
diff --git a/tests/test_dynamodb/test_dynamodb.py b/tests/test_dynamodb/test_dynamodb.py
index 592aa8407..d798b9236 100644
--- a/tests/test_dynamodb/test_dynamodb.py
+++ b/tests/test_dynamodb/test_dynamodb.py
@@ -5497,3 +5497,81 @@ def test_query_with_unknown_last_evaluated_key(table_name=None):
         ExclusiveStartKey=different_key,
     )
     assert p4["Items"] == [{"pk": {"S": "hash_value"}, "sk": {"S": "range_value6"}}]
+
+
+@mock_aws
+def test_query_with_gsi_reverse_paginated():
+    client = boto3.client("dynamodb", region_name="us-west-2")
+    table_name = "unit-test-table"
+    index_name = "alternate"
+
+    # Create table - GSI has dissimilar attributes from the main table
+    client.create_table(
+        AttributeDefinitions=[
+            {"AttributeName": "pri", "AttributeType": "S"},
+            {"AttributeName": "alt", "AttributeType": "S"},
+        ],
+        TableName=table_name,
+        KeySchema=[
+            {"AttributeName": "pri", "KeyType": "HASH"},
+        ],
+        GlobalSecondaryIndexes=[
+            {
+                "IndexName": index_name,
+                "KeySchema": [
+                    {"AttributeName": "alt", "KeyType": "HASH"},
+                    {"AttributeName": "pri", "KeyType": "RANGE"},
+                ],
+                "Projection": {"ProjectionType": "KEYS_ONLY"},
+            },
+        ],
+        BillingMode="PAY_PER_REQUEST",
+    )
+
+    # Add some items. These are chosen so that the order of addition is not
+    # lexical order for the chosen values, i.e.
+    # pri: pri_5, pri_6, pri_7, pri_8, pri_9, pri_10, pri_11, pri_12, pri_13, pri_14
+    # alt: alt_1, alt_1, alt_1, alt_2, alt_2,  alt_2,  alt_2,  alt_3,  alt_3,  alt_3
+    for i in range(5, 15):
+        client.put_item(
+            TableName=table_name,
+            Item={
+                "pri": {"S": f"pri_{i}"},
+                "alt": {"S": f"alt_{i//4}"},
+            },
+        )
+
+    # Let's reverse-sort a query on the alternate index. These items match "alt_2":
+    # in insertion order: pri_8, pri_9, pri_10, pri_11
+    # in lexical order: pri_10, pri_11, pri_8, pri_9
+    # in reverse order: pri_9, pri_8, pri_11, pri_10
+    p1 = client.query(
+        TableName=table_name,
+        IndexName=index_name,
+        KeyConditionExpression="#h = :h",
+        ExpressionAttributeNames={"#h": "alt"},
+        ExpressionAttributeValues={":h": {"S": "alt_2"}},
+        ScanIndexForward=False,
+        Limit=2,
+    )
+    assert p1["Items"] == [
+        {"pri": {"S": "pri_9"}, "alt": {"S": "alt_2"}},
+        {"pri": {"S": "pri_8"}, "alt": {"S": "alt_2"}},
+    ]
+
+    # Fetch the second page of results
+    p2 = client.query(
+        TableName=table_name,
+        IndexName=index_name,
+        KeyConditionExpression="#h = :h",
+        ExpressionAttributeNames={"#h": "alt"},
+        ExpressionAttributeValues={":h": {"S": "alt_2"}},
+        ScanIndexForward=False,
+        Limit=2,
+        ExclusiveStartKey=p1["LastEvaluatedKey"],
+    )
+    assert p2["Items"] == [
+        {"pri": {"S": "pri_11"}, "alt": {"S": "alt_2"}},
+        {"pri": {"S": "pri_10"}, "alt": {"S": "alt_2"}},
+    ]
+    assert "LastEvaluatedKey" not in p2
diff --git a/tests/test_dynamodb/test_dynamodb_batch_get_item.py b/tests/test_dynamodb/test_dynamodb_batch_get_item.py
index 06d639c6f..f4afadcf4 100644
--- a/tests/test_dynamodb/test_dynamodb_batch_get_item.py
+++ b/tests/test_dynamodb/test_dynamodb_batch_get_item.py
@@ -1,33 +1,12 @@
-import boto3
 import pytest
 from botocore.exceptions import ClientError
 
 from moto import mock_aws
 
 
-def _create_user_table():
-    client = boto3.client("dynamodb", region_name="us-east-1")
-    client.create_table(
-        TableName="users",
-        KeySchema=[{"AttributeName": "username", "KeyType": "HASH"}],
-        AttributeDefinitions=[{"AttributeName": "username", "AttributeType": "S"}],
-        ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5},
-    )
-    client.put_item(
-        TableName="users", Item={"username": {"S": "user1"}, "binaryfoo": {"B": b"bar"}}
-    )
-    client.put_item(
-        TableName="users", Item={"username": {"S": "user2"}, "foo": {"S": "bar"}}
-    )
-    client.put_item(
-        TableName="users", Item={"username": {"S": "user3"}, "foo": {"S": "bar"}}
-    )
-    return client
-
-
 @mock_aws
-def test_batch_items_returns_all():
-    dynamodb = _create_user_table()
+def test_batch_items_returns_all(create_user_table):
+    dynamodb = create_user_table
     returned_items = dynamodb.batch_get_item(
         RequestItems={
             "users": {
@@ -48,8 +27,10 @@ def test_batch_items_returns_all():
 
 
 @mock_aws
-def test_batch_items_throws_exception_when_requesting_100_items_for_single_table():
-    dynamodb = _create_user_table()
+def test_batch_items_throws_exception_when_requesting_100_items_for_single_table(
+    create_user_table,
+):
+    dynamodb = create_user_table
     with pytest.raises(ClientError) as ex:
         dynamodb.batch_get_item(
             RequestItems={
@@ -70,8 +51,10 @@ def test_batch_items_throws_exception_when_requesting_100_items_for_single_table
 
 
 @mock_aws
-def test_batch_items_throws_exception_when_requesting_100_items_across_all_tables():
-    dynamodb = _create_user_table()
+def test_batch_items_throws_exception_when_requesting_100_items_across_all_tables(
+    create_user_table,
+):
+    dynamodb = create_user_table
     with pytest.raises(ClientError) as ex:
         dynamodb.batch_get_item(
             RequestItems={
@@ -95,8 +78,8 @@ def test_batch_items_throws_exception_when_requesting_100_items_across_all_table
 
 
 @mock_aws
-def test_batch_items_with_basic_projection_expression():
-    dynamodb = _create_user_table()
+def test_batch_items_with_basic_projection_expression(create_user_table):
+    dynamodb = create_user_table
     returned_items = dynamodb.batch_get_item(
         RequestItems={
             "users": {
@@ -142,8 +125,10 @@ def test_batch_items_with_basic_projection_expression():
 
 
 @mock_aws
-def test_batch_items_with_basic_projection_expression_and_attr_expression_names():
-    dynamodb = _create_user_table()
+def test_batch_items_with_basic_projection_expression_and_attr_expression_names(
+    create_user_table,
+):
+    dynamodb = create_user_table
     returned_items = dynamodb.batch_get_item(
         RequestItems={
             "users": {
@@ -167,8 +152,8 @@ def test_batch_items_with_basic_projection_expression_and_attr_expression_names(
 
 
 @mock_aws
-def test_batch_items_should_throw_exception_for_duplicate_request():
-    client = _create_user_table()
+def test_batch_items_should_throw_exception_for_duplicate_request(create_user_table):
+    client = create_user_table
     with pytest.raises(ClientError) as ex:
         client.batch_get_item(
             RequestItems={
@@ -187,7 +172,7 @@ def test_batch_items_should_throw_exception_for_duplicate_request():
 
 
 @mock_aws
-def test_batch_items_should_return_16mb_max():
+def test_batch_items_should_return_16mb_max(create_user_table):
     """
     A single operation can retrieve up to 16 MB of data [...]. BatchGetItem returns a partial result if the response size limit is exceeded [..].
 
@@ -197,7 +182,7 @@ def test_batch_items_should_return_16mb_max():
     It also returns an appropriate UnprocessedKeys value so you can get the next page of results.
     If desired, your application can include its own logic to assemble the pages of results into one dataset.
     """
-    client = _create_user_table()
+    client = create_user_table
     # Fill table with all the data
     for i in range(100):
         client.put_item(
diff --git a/tests/test_secretsmanager/test_secretsmanager.py b/tests/test_secretsmanager/test_secretsmanager.py
index 8d2d1f7ff..e386aff25 100644
--- a/tests/test_secretsmanager/test_secretsmanager.py
+++ b/tests/test_secretsmanager/test_secretsmanager.py
@@ -181,6 +181,127 @@ def test_get_secret_version_stage_mismatch():
     ) == cm.value.response["Error"]["Message"]
 
 
+@mock_aws
+def test_batch_get_secret_value_for_secret_id_list_with_matches():
+    conn = boto3.client("secretsmanager", region_name="us-west-2")
+
+    secret_a = conn.create_secret(Name="test-secret-a", SecretString="secret")
+    secret_b = conn.create_secret(Name="test-secret-b", SecretString="secret")
+
+    secrets_batch = conn.batch_get_secret_value(
+        SecretIdList=["test-secret-a", "test-secret-b"]
+    )
+    matched = [
+        secret
+        for secret in secrets_batch["SecretValues"]
+        if secret["ARN"] in [secret_a["ARN"], secret_b["ARN"]]
+    ]
+
+    assert len(matched) == len(secrets_batch["SecretValues"]) == 2
+
+
+@mock_aws
+def test_batch_get_secret_value_for_secret_id_list_without_matches():
+    conn = boto3.client("secretsmanager", region_name="us-west-2")
+
+    conn.create_secret(Name="test-secret-a", SecretString="secret")
+
+    secrets_batch = conn.batch_get_secret_value(
+        SecretIdList=["test-secret-b", "test-secret-c"]
+    )
+    assert len(secrets_batch["SecretValues"]) == 0
+
+
+@mock_aws
+def test_batch_get_secret_value_with_filters():
+    conn = boto3.client("secretsmanager", region_name="us-west-2")
+
+    secret_a = conn.create_secret(Name="test-secret-a", SecretString="secret")
+    secret_b = conn.create_secret(Name="test-secret-b", SecretString="secret")
+    conn.create_secret(Name="test-secret-c", SecretString="secret")
+
+    secrets_batch = conn.batch_get_secret_value(
+        Filters=[{"Key": "name", "Values": [secret_a["Name"], secret_b["Name"]]}]
+    )
+
+    assert [sec["ARN"] for sec in secrets_batch["SecretValues"]] == [
+        secret_a["ARN"],
+        secret_b["ARN"],
+    ]
+
+
+@mock_aws
+def test_batch_get_secret_value_with_both_secret_id_list_and_filters():
+    conn = boto3.client("secretsmanager", region_name="us-west-2")
+
+    with pytest.raises(ClientError) as exc:
+        conn.batch_get_secret_value(
+            Filters=[{"Key": "name", "Values": ["test-secret-a", "test-secret-b"]}],
+            SecretIdList=["foo", "bar"],
+        )
+
+    err = exc.value.response["Error"]
+    assert err["Code"] == "InvalidParameterException"
+    assert (
+        "Either 'SecretIdList' or 'Filters' must be provided, but not both."
+        in err["Message"]
+    )
+
+
+@mock_aws
+def test_batch_get_secret_value_with_max_results_and_no_filters():
+    conn = boto3.client("secretsmanager", region_name="us-west-2")
+
+    conn = boto3.client("secretsmanager", region_name="us-west-2")
+    with pytest.raises(ClientError) as exc:
+        conn.batch_get_secret_value(MaxResults=10, SecretIdList=["foo", "bar"])
+
+    err = exc.value.response["Error"]
+    assert err["Code"] == "InvalidParameterException"
+    assert (
+        "'Filters' not specified. 'Filters' must also be specified when 'MaxResults' is provided."
+        in err["Message"]
+    )
+
+
+@mock_aws
+def test_batch_get_secret_value_binary():
+    conn = boto3.client("secretsmanager", region_name="us-west-2")
+
+    secret_a = conn.create_secret(Name="test-secret-a", SecretBinary="secretA")
+    secret_b = conn.create_secret(Name="test-secret-b", SecretBinary="secretB")
+    conn.create_secret(Name="test-secret-c", SecretBinary="secretC")
+
+    secrets_batch = conn.batch_get_secret_value(
+        Filters=[{"Key": "name", "Values": [secret_a["Name"], secret_b["Name"]]}]
+    )
+    matched = [
+        secret
+        for secret in secrets_batch["SecretValues"]
+        if secret["ARN"] == secret_a["ARN"]
+        and secret["SecretBinary"] == b"secretA"
+        or secret["ARN"] == secret_b["ARN"]
+        and secret["SecretBinary"] == b"secretB"
+    ]
+    assert len(matched) == 2
+
+
+@mock_aws
+def test_batch_get_secret_value_missing_value():
+    conn = boto3.client("secretsmanager", region_name="us-east-2")
+
+    secret_a = conn.create_secret(Name="test-secret-a")
+    secret_b = conn.create_secret(Name="test-secret-b")
+
+    with pytest.raises(ClientError) as exc:
+        conn.batch_get_secret_value(
+            Filters=[{"Key": "name", "Values": [secret_a["Name"], secret_b["Name"]]}]
+        )
+
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ResourceNotFoundException"
+
+
 @mock_aws
 def test_create_secret():
     conn = boto3.client("secretsmanager", region_name="us-east-1")
diff --git a/tests/test_secretsmanager/test_server.py b/tests/test_secretsmanager/test_server.py
index d6b5ae8ec..43c90388a 100644
--- a/tests/test_secretsmanager/test_server.py
+++ b/tests/test_secretsmanager/test_server.py
@@ -912,6 +912,120 @@ def test_update_secret_version_stage_validation():
     assert resp.status_code == 400
 
 
+@mock_aws
+def test_batch_get_secret_value_for_secret_id_list_with_matches():
+    backend = server.create_backend_app("secretsmanager")
+    test_client = backend.test_client()
+
+    test_client.post(
+        "/",
+        data={"Name": "db/username", "SecretString": "foo"},
+        headers={"X-Amz-Target": "secretsmanager.CreateSecret"},
+    )
+
+    test_client.post(
+        "/",
+        data={"Name": "db/password", "SecretString": "foo-password"},
+        headers={"X-Amz-Target": "secretsmanager.CreateSecret"},
+    )
+
+    batch_get_secret_values = test_client.post(
+        "/",
+        json={"SecretIdList": ["db/username", "db/password"]},
+        headers={"X-Amz-Target": "secretsmanager.BatchGetSecretValue"},
+    )
+
+    json_data = json.loads(batch_get_secret_values.data.decode("utf-8"))
+    matched = [
+        s
+        for s in json_data["SecretValues"]
+        if (s["Name"] == "db/username" and s["SecretString"] == "foo")
+        or (s["Name"] == "db/password" and s["SecretString"] == "foo-password")
+    ]
+    assert len(matched) == len(json_data["SecretValues"]) == 2
+
+
+@mock_aws
+def test_batch_get_secret_value_for_secret_id_list_with_no_matches():
+    backend = server.create_backend_app("secretsmanager")
+    test_client = backend.test_client()
+
+    test_client.post(
+        "/",
+        data={"Name": "db/foo", "SecretString": "bar"},
+        headers={"X-Amz-Target": "secretsmanager.CreateSecret"},
+    )
+
+    batch_get_secret_values = test_client.post(
+        "/",
+        json={"SecretIdList": ["db/username", "db/password"]},
+        headers={"X-Amz-Target": "secretsmanager.BatchGetSecretValue"},
+    )
+
+    json_data = json.loads(batch_get_secret_values.data.decode("utf-8"))
+    assert len(json_data["SecretValues"]) == 0
+
+
+@mock_aws
+def test_batch_get_secret_value_with_both_secret_id_list_and_filters():
+    backend = server.create_backend_app("secretsmanager")
+    test_client = backend.test_client()
+
+    batch_get_secret_values = test_client.post(
+        "/",
+        json={
+            "SecretIdList": ["db/username", "db/password"],
+            "Filters": [{"Key": "description", "Values": ["foo"]}],
+        },
+        headers={"X-Amz-Target": "secretsmanager.BatchGetSecretValue"},
+    )
+
+    json_data = json.loads(batch_get_secret_values.data.decode("utf-8"))
+    assert (
+        json_data["message"]
+        == "Either 'SecretIdList' or 'Filters' must be provided, but not both."
+    )
+    assert json_data["__type"] == "InvalidParameterException"
+    assert batch_get_secret_values.status_code == 400
+
+
+@mock_aws
+def test_batch_get_secret_value_with_filters():
+    backend = server.create_backend_app("secretsmanager")
+    test_client = backend.test_client()
+
+    test_client.post(
+        "/",
+        data={"Name": "db/foo", "SecretString": "bar", "Description": "foo-secret"},
+        headers={"X-Amz-Target": "secretsmanager.CreateSecret"},
+    )
+
+    test_client.post(
+        "/",
+        data={"Name": "db/bar", "SecretString": "foo", "Description": "foo-secret"},
+        headers={"X-Amz-Target": "secretsmanager.CreateSecret"},
+    )
+
+    batch_get_secret_values = test_client.post(
+        "/",
+        json={
+            "Filters": [{"Key": "description", "Values": ["foo-secret"]}],
+        },
+        headers={"X-Amz-Target": "secretsmanager.BatchGetSecretValue"},
+    )
+
+    json_data = json.loads(batch_get_secret_values.data.decode("utf-8"))
+    matched = [
+        s
+        for s in json_data["SecretValues"]
+        if (s["Name"] == "db/foo" and s["SecretString"] == "bar")
+        or (s["Name"] == "db/bar" and s["SecretString"] == "foo")
+    ]
+
+    json_data = json.loads(batch_get_secret_values.data.decode("utf-8"))
+    assert len(json_data["SecretValues"]) == len(matched) == 2
+
+
 #
 # The following tests should work, but fail on the embedded dict in
 # RotationRules. The error message suggests a problem deeper in the code, which