Repository: getmoto/moto
Pull request #8806 (2025-04-14 16:32:17)

[SES] Configuration Set List Pagination
Fixes SES & SESv2 configuration set pagination

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/8806?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 92.82%. Comparing base [(`a814d2c`)](https://app.codecov.io/gh/getmoto/moto/commit/a814d2ca6c9c2600d8a96a00ab44d4b92b2d9fb0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`3fd306b`)](https://app.codecov.io/gh/getmoto/moto/commit/3fd306b441fa858325ea4cae29b6a03cf592b0e3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #8806   +/-   ##
=======================================
  Coverage   92.82%   92.82%           
=======================================
  Files        1266     1266           
  Lines      110461   110468    +7     
=======================================
+ Hits       102533   102540    +7     
  Misses       7928     7928           
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/8806/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/8806/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `28.11% <46.15%> (+<0.01%)` | :arrow_up: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/8806/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `92.79% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getmoto/moto/pull/8806?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index 17378c8c7..beb10ffb5 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -9336,7 +9336,7 @@
 - [X] detect_document_text
 - [ ] get_adapter
 - [ ] get_adapter_version
-- [ ] get_document_analysis
+- [X] get_document_analysis
 - [X] get_document_text_detection
 - [ ] get_expense_analysis
 - [ ] get_lending_analysis
@@ -9344,7 +9344,7 @@
 - [ ] list_adapter_versions
 - [ ] list_adapters
 - [ ] list_tags_for_resource
-- [ ] start_document_analysis
+- [X] start_document_analysis
 - [X] start_document_text_detection
 - [ ] start_expense_analysis
 - [ ] start_lending_analysis
diff --git a/docs/docs/services/textract.rst b/docs/docs/services/textract.rst
index 6a16fb11a..664848380 100644
--- a/docs/docs/services/textract.rst
+++ b/docs/docs/services/textract.rst
@@ -26,7 +26,7 @@ textract
 - [X] detect_document_text
 - [ ] get_adapter
 - [ ] get_adapter_version
-- [ ] get_document_analysis
+- [X] get_document_analysis
 - [X] get_document_text_detection
   
         Pagination has not yet been implemented
@@ -38,7 +38,7 @@ textract
 - [ ] list_adapter_versions
 - [ ] list_adapters
 - [ ] list_tags_for_resource
-- [ ] start_document_analysis
+- [X] start_document_analysis
 - [X] start_document_text_detection
   
         The following parameters have not yet been implemented: ClientRequestToken, JobTag, NotificationChannel, OutputConfig, KmsKeyID
diff --git a/moto/acm/models.py b/moto/acm/models.py
index 396e711b5..913b9da9c 100644
--- a/moto/acm/models.py
+++ b/moto/acm/models.py
@@ -229,7 +229,9 @@ class CertBundle(BaseModel):
             certificate=cert_armored,
             private_key=private_key,
             cert_type="PRIVATE" if cert_authority_arn is not None else "AMAZON_ISSUED",
-            cert_status="PENDING_VALIDATION",
+            cert_status="ISSUED"
+            if cert_authority_arn is not None
+            else "PENDING_VALIDATION",
             cert_authority_arn=cert_authority_arn,
             account_id=account_id,
             region=region,
diff --git a/moto/ses/models.py b/moto/ses/models.py
index 9920f6283..69fd3a3b8 100644
--- a/moto/ses/models.py
+++ b/moto/ses/models.py
@@ -11,6 +11,7 @@ from moto.core.base_backend import BackendDict, BaseBackend
 from moto.core.common_models import BaseModel
 from moto.core.utils import utcnow
 from moto.sns.models import sns_backends
+from moto.utilities.paginator import paginate
 
 from .exceptions import (
     AlreadyExists,
@@ -37,7 +38,7 @@ PAGINATION_MODEL = {
         "input_token": "next_token",
         "limit_key": "max_items",
         "limit_default": 100,
-        "unique_attribute": ["configuration_set_name"],
+        "unique_attribute": "configuration_set_name",
     },
 }
 
@@ -501,10 +502,12 @@ class SESBackend(BaseBackend):
     def delete_configuration_set(self, configuration_set_name: str) -> None:
         self.config_sets.pop(configuration_set_name)
 
-    def list_configuration_sets(
-        self, next_token: Optional[str], max_items: Optional[int]
-    ) -> List[str]:
-        return list(self.config_sets.keys())
+    @paginate(pagination_model=PAGINATION_MODEL)
+    def list_configuration_sets(self) -> List[ConfigurationSet]:
+        return list(self.config_sets.values())
+
+    def _list_all_configuration_sets(self) -> List[ConfigurationSet]:
+        return list(self.config_sets.values())
 
     def create_configuration_set_event_destination(
         self, configuration_set_name: str, event_destination: Dict[str, Any]
diff --git a/moto/ses/responses.py b/moto/ses/responses.py
index 3499fcee1..1b6cf00d7 100644
--- a/moto/ses/responses.py
+++ b/moto/ses/responses.py
@@ -392,13 +392,14 @@ class EmailResponse(BaseResponse):
         params = self._get_params()
         next_token = params.get("NextToken")
         max_items = params.get("MaxItems")
-        configuration_sets = self.backend.list_configuration_sets(
+        configuration_sets, next_token = self.backend.list_configuration_sets(
             next_token=next_token,
             max_items=max_items,
         )
+        config_set_names = [c.configuration_set_name for c in configuration_sets]
         template = self.response_template(LIST_CONFIGURATION_SETS_TEMPLATE)
         return template.render(
-            configuration_sets=configuration_sets, next_token=next_token
+            configuration_sets=config_set_names, next_token=next_token
         )
 
 
diff --git a/moto/sesv2/models.py b/moto/sesv2/models.py
index 4925fea61..20e480581 100644
--- a/moto/sesv2/models.py
+++ b/moto/sesv2/models.py
@@ -23,6 +23,12 @@ PAGINATION_MODEL = {
         "limit_default": 100,
         "unique_attribute": "IdentityName",
     },
+    "list_configuration_sets": {
+        "input_token": "next_token",
+        "limit_key": "page_size",
+        "limit_default": 100,
+        "unique_attribute": "configuration_set_name",
+    },
 }
 
 
@@ -309,10 +315,9 @@ class SESV2Backend(BaseBackend):
         )
         return config_set
 
-    def list_configuration_sets(self, next_token: str, page_size: int) -> List[str]:
-        return self.v1_backend.list_configuration_sets(
-            next_token=next_token, max_items=page_size
-        )
+    @paginate(pagination_model=PAGINATION_MODEL)
+    def list_configuration_sets(self) -> List[ConfigurationSet]:
+        return self.v1_backend._list_all_configuration_sets()
 
     def create_dedicated_ip_pool(
         self, pool_name: str, tags: List[Dict[str, str]], scaling_mode: str
diff --git a/moto/sesv2/responses.py b/moto/sesv2/responses.py
index 9a3e91c0e..4a1d2ec0f 100644
--- a/moto/sesv2/responses.py
+++ b/moto/sesv2/responses.py
@@ -183,11 +183,13 @@ class SESV2Response(BaseResponse):
     def list_configuration_sets(self) -> str:
         next_token = self._get_param("NextToken")
         page_size = self._get_param("PageSize")
-        configuration_sets = self.sesv2_backend.list_configuration_sets(
+        configuration_sets, next_token = self.sesv2_backend.list_configuration_sets(
             next_token=next_token, page_size=page_size
         )
+        config_set_names = [c.configuration_set_name for c in configuration_sets]
+
         return json.dumps(
-            dict(ConfigurationSets=configuration_sets, NextToken=next_token)
+            dict(ConfigurationSets=config_set_names, NextToken=next_token)
         )
 
     def create_dedicated_ip_pool(self) -> str:
diff --git a/moto/textract/models.py b/moto/textract/models.py
index 11078421d..86e66307b 100644
--- a/moto/textract/models.py
+++ b/moto/textract/models.py
@@ -1,5 +1,5 @@
 from collections import defaultdict
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Optional
 
 from moto.core.base_backend import BackendDict, BaseBackend
 from moto.core.common_models import BaseModel
@@ -33,6 +33,7 @@ class TextractBackend(BaseBackend):
     def __init__(self, region_name: str, account_id: str):
         super().__init__(region_name, account_id)
         self.async_text_detection_jobs: Dict[str, TextractJob] = defaultdict()
+        self.async_document_analysis_jobs: Dict[str, TextractJob] = defaultdict()
 
     def get_document_text_detection(self, job_id: str) -> TextractJob:
         """
@@ -67,5 +68,32 @@ class TextractBackend(BaseBackend):
         )
         return job_id
 
+    def start_document_analysis(
+        self, document_location: dict[str, Any], feature_types: List[str]
+    ) -> str:
+        """
+        The following parameters have not yet been implemented: ClientRequestToken, JobTag, NotificationChannel, OutputConfig, KmsKeyID
+        """
+        if not document_location or not feature_types:
+            raise InvalidParameterException()
+        job_id = str(mock_random.uuid4())
+        self.async_document_analysis_jobs[job_id] = TextractJob(
+            {
+                "Blocks": TextractBackend.BLOCKS,
+                "DetectDocumentTextModelVersion": "1.0",
+                "DocumentMetadata": {"Pages": TextractBackend.PAGES},
+                "JobStatus": TextractBackend.JOB_STATUS,
+            }
+        )
+        return job_id
+
+    def get_document_analysis(
+        self, job_id: str, max_results: Optional[int], next_token: Optional[str] = None
+    ) -> TextractJob:
+        job = self.async_document_analysis_jobs.get(job_id)
+        if not job:
+            raise InvalidJobIdException()
+        return job
+
 
 textract_backends = BackendDict(TextractBackend, "textract")
diff --git a/moto/textract/responses.py b/moto/textract/responses.py
index adc95b0ff..5aeaeed0f 100644
--- a/moto/textract/responses.py
+++ b/moto/textract/responses.py
@@ -35,3 +35,22 @@ class TextractResponse(BaseResponse):
             document_location=document_location
         )
         return json.dumps(dict(JobId=job_id))
+
+    def start_document_analysis(self) -> str:
+        params = json.loads(self.body)
+        document_location = params.get("DocumentLocation")
+        feature_types = params.get("FeatureTypes")
+        job_id = self.textract_backend.start_document_analysis(
+            document_location=document_location, feature_types=feature_types
+        )
+        return json.dumps(dict(JobId=job_id))
+
+    def get_document_analysis(self) -> str:
+        params = json.loads(self.body)
+        job_id = params.get("JobId")
+        max_results = params.get("MaxResults")
+        next_token = params.get("NextToken")
+        job = self.textract_backend.get_document_analysis(
+            job_id=job_id, max_results=max_results, next_token=next_token
+        ).to_dict()
+        return json.dumps(job)


Test patch:
diff --git a/.github/workflows/tests_sdk_ruby.yml b/.github/workflows/tests_sdk_ruby.yml
index 389a1024d..7fcf98d8f 100644
--- a/.github/workflows/tests_sdk_ruby.yml
+++ b/.github/workflows/tests_sdk_ruby.yml
@@ -13,7 +13,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - name: Set up Ruby ${{ matrix.ruby-version }}
-        uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252
+        uses: ruby/setup-ruby@e5ac7b085f6e63d49c8973eb0c6e04d876b881f1
         with:
           ruby-version: ${{ matrix.ruby-version }}
       - name: Set up Python 3.12
diff --git a/tests/test_acm/test_acm.py b/tests/test_acm/test_acm.py
index 97101da22..8052d56ca 100644
--- a/tests/test_acm/test_acm.py
+++ b/tests/test_acm/test_acm.py
@@ -261,7 +261,7 @@ def test_describe_certificate_with_pca_cert():
     assert resp["Certificate"]["DomainName"] == SERVER_COMMON_NAME
     assert resp["Certificate"]["Issuer"] == "Amazon"
     assert resp["Certificate"]["KeyAlgorithm"] == "RSA_2048"
-    assert resp["Certificate"]["Status"] == "PENDING_VALIDATION"
+    assert resp["Certificate"]["Status"] == "ISSUED"
     assert resp["Certificate"]["Type"] == "PRIVATE"
     assert resp["Certificate"]["RenewalEligibility"] == "INELIGIBLE"
     assert "Options" in resp["Certificate"]
diff --git a/tests/test_sesv2/test_sesv2.py b/tests/test_sesv2/test_sesv2.py
index c4fef0c53..7428023d0 100644
--- a/tests/test_sesv2/test_sesv2.py
+++ b/tests/test_sesv2/test_sesv2.py
@@ -713,7 +713,7 @@ def test_list_configuration_sets():
     # Validate
     assert len(config_sets["ConfigurationSets"]) == 2
     for c in config_sets["ConfigurationSets"]:
-        assert c in {name_v1, name_v2}
+        assert c in [name_v1, name_v2]
 
 
 @mock_aws
diff --git a/tests/test_textract/test_server.py b/tests/test_textract/test_server.py
index 0bf2ffc25..09c9224c0 100644
--- a/tests/test_textract/test_server.py
+++ b/tests/test_textract/test_server.py
@@ -93,3 +93,48 @@ def test_textract_get_text_detection_without_job_id():
     data = json.loads(resp.data.decode("utf-8"))
     assert data["__type"] == "InvalidJobIdException"
     assert data["message"] == "An invalid job identifier was passed."
+
+
+@mock_aws
+def test_textract_start_document_analysis():
+    backend = server.create_backend_app("textract")
+    test_client = backend.test_client()
+
+    headers = {"X-Amz-Target": "X-Amz-Target=Textract.StartDocumentAnalysis"}
+    request_body = {
+        "DocumentLocation": {
+            "S3Object": {"Bucket": "bucket", "Name": "name", "Version": "version"}
+        },
+        "FeatureTypes": ["TABLES", "FORMS"],
+    }
+
+    resp = test_client.post("/", headers=headers, json=request_body)
+    data = json.loads(resp.data.decode("utf-8"))
+    assert resp.status_code == 200
+    assert isinstance(data["JobId"], str)
+
+
+@mock_aws
+def test_textract_get_document_analysis():
+    backend = server.create_backend_app("textract")
+    test_client = backend.test_client()
+
+    headers = {"X-Amz-Target": "X-Amz-Target=Textract.StartDocumentAnalysis"}
+    request_body = {
+        "DocumentLocation": {
+            "S3Object": {"Bucket": "bucket", "Name": "name", "Version": "version"}
+        },
+        "FeatureTypes": ["TABLES", "FORMS"],
+    }
+
+    resp = test_client.post("/", headers=headers, json=request_body)
+    start_job_data = json.loads(resp.data.decode("utf-8"))
+
+    headers = {"X-Amz-Target": "X-Amz-Target=Textract.GetDocumentAnalysis"}
+    request_body = {
+        "JobId": start_job_data["JobId"],
+    }
+    resp = test_client.post("/", headers=headers, json=request_body)
+    assert resp.status_code == 200
+    data = json.loads(resp.data.decode("utf-8"))
+    assert data["JobStatus"] == "SUCCEEDED"
diff --git a/tests/test_textract/test_textract.py b/tests/test_textract/test_textract.py
index 7306a3a48..023b66bb7 100644
--- a/tests/test_textract/test_textract.py
+++ b/tests/test_textract/test_textract.py
@@ -85,3 +85,62 @@ def test_detect_document_text():
     )
     assert isinstance(result["Blocks"], list)
     assert result["DetectDocumentTextModelVersion"] == "1.0"
+
+
+@mock_aws
+def test_get_document_analysis():
+    if settings.TEST_SERVER_MODE:
+        raise SkipTest("Cannot set textract backend values in server mode")
+
+    TextractBackend.JOB_STATUS = "SUCCEEDED"
+    TextractBackend.PAGES = randint(5, 500)
+    TextractBackend.BLOCKS = [
+        {
+            "Text": "This is a test",
+            "Id": "0",
+            "Confidence": "100",
+            "Geometry": {
+                "BoundingBox": {
+                    "Width": "0.5",
+                    "Height": "0.5",
+                    "Left": "0.5",
+                    "Top": "0.5",
+                },
+            },
+        }
+    ]
+
+    client = boto3.client("textract", region_name="us-east-1")
+    job = client.start_document_analysis(
+        DocumentLocation={"S3Object": {"Bucket": "bucket", "Name": "name"}},
+        FeatureTypes=["TABLES", "FORMS"],
+    )
+
+    resp = client.get_document_analysis(JobId=job["JobId"], MaxResults=1000)
+
+    assert resp["Blocks"][0]["Text"] == "This is a test"
+    assert resp["Blocks"][0]["Id"] == "0"
+    assert resp["Blocks"][0]["Confidence"] == "100"
+    assert resp["Blocks"][0]["Geometry"]["BoundingBox"]["Width"] == "0.5"
+    assert resp["Blocks"][0]["Geometry"]["BoundingBox"]["Height"] == "0.5"
+    assert resp["Blocks"][0]["Geometry"]["BoundingBox"]["Left"] == "0.5"
+    assert resp["Blocks"][0]["Geometry"]["BoundingBox"]["Top"] == "0.5"
+    assert resp["JobStatus"] == "SUCCEEDED"
+    assert resp["DocumentMetadata"]["Pages"] == TextractBackend.PAGES
+
+
+@mock_aws
+def test_start_document_analysis():
+    client = boto3.client("textract", region_name="us-east-1")
+    resp = client.start_document_analysis(
+        DocumentLocation={"S3Object": {"Bucket": "bucket", "Name": "name"}},
+        FeatureTypes=["TABLES", "FORMS"],
+        ClientRequestToken="unique-token",
+        JobTag="test-job",
+        NotificationChannel={
+            "SNSTopicArn": "arn:aws:sns:us-east-1:123456789012:AmazonTextractTopic",
+            "RoleArn": "arn:aws:iam::123456789012:role/TextractRole",
+        },
+    )
+
+    assert "JobId" in resp