Repository: getmoto/moto
Pull request #8817 (2025-04-15 19:39:28)

Batch: Accept compute env name in computeEnvironmentOrder when creating job queue
When creating a Batch Job queue AWS allows to provide a compute env name instead of its ARN as part of the `--compute-environment-order` argument. Although the documentation mentions only the ARN https://docs.aws.amazon.com/cli/latest/reference/batch/create-job-queue.html the operation works if the name is provided.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/8817?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 [(`ec3224f`)](https://app.codecov.io/gh/getmoto/moto/commit/ec3224f772c8c3b46830f99e8dbc31944124e552?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`2ed7c6a`)](https://app.codecov.io/gh/getmoto/moto/commit/2ed7c6a2edb11d9e33b35e25e123887cb52cc2df?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    #8817   +/-   ##
=======================================
  Coverage   92.82%   92.82%           
=======================================
  Files        1266     1266           
  Lines      110461   110461           
=======================================
  Hits       102533   102533           
  Misses       7928     7928           
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/8817/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/8817/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `28.11% <0.00%> (ø)` | |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/8817/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%> (ø)` | |

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/8817?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/batch/models.py b/moto/batch/models.py
index 274737536..d54d55ab0 100644
--- a/moto/batch/models.py
+++ b/moto/batch/models.py
@@ -1344,7 +1344,10 @@ class BatchBackend(BaseBackend):
                     "Error executing request, Exception : Instance role is required."
                 )
             for profile in self.iam_backend.get_instance_profiles():
-                if profile.arn == cr["instanceRole"]:
+                if (
+                    profile.arn == cr["instanceRole"]
+                    or profile.name == cr["instanceRole"]
+                ):
                     break
             else:
                 raise InvalidParameterValueException(
@@ -1549,11 +1552,13 @@ class BatchBackend(BaseBackend):
                 for item in sorted(compute_env_order, key=lambda x: x["order"])
             ]
             env_objects = []
-            # Check each ARN exists, then make a list of compute env's
-            for arn in ordered_compute_environments:
-                env = self.get_compute_environment_by_arn(arn)
+            # Check each compute env exists, then make a list of them
+            for identifier in ordered_compute_environments:
+                env = self.get_compute_environment(identifier)
                 if env is None:
-                    raise ClientException(f"Compute environment {arn} does not exist")
+                    raise ClientException(
+                        f"Compute environment {identifier} does not exist"
+                    )
                 env_objects.append(env)
         except Exception:
             raise ClientException("computeEnvironmentOrder is malformed")
diff --git a/moto/resourcegroupstaggingapi/models.py b/moto/resourcegroupstaggingapi/models.py
index 71e3147ba..37f6f7cce 100644
--- a/moto/resourcegroupstaggingapi/models.py
+++ b/moto/resourcegroupstaggingapi/models.py
@@ -19,6 +19,10 @@ from moto.glacier.models import GlacierBackend, glacier_backends
 from moto.glue.models import GlueBackend, glue_backends
 from moto.kafka.models import KafkaBackend, kafka_backends
 from moto.kinesis.models import KinesisBackend, kinesis_backends
+from moto.kinesisanalyticsv2.models import (
+    KinesisAnalyticsV2Backend,
+    kinesisanalyticsv2_backends,
+)
 from moto.kms.models import KmsBackend, kms_backends
 from moto.lexv2models.models import LexModelsV2Backend, lexv2models_backends
 from moto.logs.models import LogsBackend, logs_backends
@@ -89,6 +93,10 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
     def kinesis_backend(self) -> KinesisBackend:
         return kinesis_backends[self.account_id][self.region_name]
 
+    @property
+    def kinesisanalyticsv2_backend(self) -> KinesisAnalyticsV2Backend:
+        return kinesisanalyticsv2_backends[self.account_id][self.region_name]
+
     @property
     def kms_backend(self) -> KmsBackend:
         return kms_backends[self.account_id][self.region_name]
@@ -510,6 +518,21 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
 
         # Kinesis
 
+        # KinesisAnalyticsV2
+        if self.kinesisanalyticsv2_backend and (
+            not resource_type_filters or "kinesisanalyticsv2" in resource_type_filters
+        ):
+            for application in self.kinesisanalyticsv2_backend.applications.values():
+                tags = self.kinesisanalyticsv2_backend.tagger.list_tags_for_resource(
+                    application.application_arn
+                )["Tags"]
+                if not tags or not tag_filter(tags):
+                    continue
+                yield {
+                    "ResourceARN": application.application_arn,
+                    "Tags": tags,
+                }
+
         # KMS
         if not resource_type_filters or "kms" in resource_type_filters:
             for kms_key in self.kms_backend.list_keys():
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/tests/test_batch/test_batch_compute_envs.py b/tests/test_batch/test_batch_compute_envs.py
index 43129da92..37b6f43b9 100644
--- a/tests/test_batch/test_batch_compute_envs.py
+++ b/tests/test_batch/test_batch_compute_envs.py
@@ -9,10 +9,16 @@ from . import _get_clients, _setup
 
 
 @mock_aws
-def test_create_managed_compute_environment():
+@pytest.mark.parametrize("use_arn_for_instance_role", [True, False])
+def test_create_managed_compute_environment(use_arn_for_instance_role):
     ec2_client, iam_client, ecs_client, _, batch_client = _get_clients()
     _, subnet_id, sg_id, iam_arn = _setup(ec2_client, iam_client)
 
+    instance_role = (
+        iam_arn.replace("role", "instance-profile")
+        if use_arn_for_instance_role
+        else iam_arn.split("/")[-1]
+    )
     compute_name = str(uuid4())
     resp = batch_client.create_compute_environment(
         computeEnvironmentName=compute_name,
@@ -28,7 +34,7 @@ def test_create_managed_compute_environment():
             "subnets": [subnet_id],
             "securityGroupIds": [sg_id],
             "ec2KeyPair": "string",
-            "instanceRole": iam_arn.replace("role", "instance-profile"),
+            "instanceRole": instance_role,
             "tags": {"string": "string"},
             "bidPercentage": 123,
             "spotIamFleetRole": "string",
diff --git a/tests/test_batch/test_batch_tags_job_queue.py b/tests/test_batch/test_batch_tags_job_queue.py
index c3d624f8b..4fccacf82 100644
--- a/tests/test_batch/test_batch_tags_job_queue.py
+++ b/tests/test_batch/test_batch_tags_job_queue.py
@@ -1,12 +1,15 @@
 from uuid import uuid4
 
+import pytest
+
 from moto import mock_aws
 
 from . import _get_clients, _setup
 
 
 @mock_aws
-def test_create_job_queue_with_tags():
+@pytest.mark.parametrize("use_compute_env_arn", [True, False])
+def test_create_job_queue_with_tags(use_compute_env_arn):
     ec2_client, iam_client, _, _, batch_client = _get_clients()
     _, _, _, iam_arn = _setup(ec2_client, iam_client)
 
@@ -17,14 +20,18 @@ def test_create_job_queue_with_tags():
         state="ENABLED",
         serviceRole=iam_arn,
     )
-    arn = resp["computeEnvironmentArn"]
+    compute_env_identifier = (
+        resp["computeEnvironmentArn"] if use_compute_env_arn else compute_name
+    )
 
     jq_name = str(uuid4())[0:6]
     resp = batch_client.create_job_queue(
         jobQueueName=jq_name,
         state="ENABLED",
         priority=123,
-        computeEnvironmentOrder=[{"order": 123, "computeEnvironment": arn}],
+        computeEnvironmentOrder=[
+            {"order": 123, "computeEnvironment": compute_env_identifier}
+        ],
         tags={"k1": "v1", "k2": "v2"},
     )
     assert "jobQueueArn" in resp
diff --git a/tests/test_kinesisanalyticsv2/test_kinesisanalyticsv2.py b/tests/test_kinesisanalyticsv2/test_kinesisanalyticsv2.py
index eb79c9e7a..421a99d8c 100644
--- a/tests/test_kinesisanalyticsv2/test_kinesisanalyticsv2.py
+++ b/tests/test_kinesisanalyticsv2/test_kinesisanalyticsv2.py
@@ -381,3 +381,24 @@ def test_list_applications():
     assert app_summaries[0].get("ApplicationStatus") == "STARTING"
     assert app_summaries[0].get("ApplicationVersionId") == 1
     assert app_summaries[0].get("RuntimeEnvironment") == "FLINK-1_20"
+
+
+@mock_aws
+def test_resource_groups_tagging_api():
+    region = "us-east-2"
+    client = boto3.client("kinesisanalyticsv2", region_name=region)
+    rtapi_client = boto3.client("resourcegroupstaggingapi", region_name=region)
+
+    app_resp = client.create_application(
+        ApplicationName="test_application",
+        RuntimeEnvironment="FLINK-1_20",
+        ServiceExecutionRole=f"arn:aws:iam::{ACCOUNT_ID}:role/application_role",
+        Tags=FAKE_TAGS,
+    )
+    app = app_resp.get("ApplicationDetail")
+    app_arn = app.get("ApplicationARN")
+
+    resp = rtapi_client.get_resources(ResourceTypeFilters=["kinesisanalyticsv2"])
+    assert len(resp["ResourceTagMappingList"]) == 1
+    assert resp["ResourceTagMappingList"][0]["ResourceARN"] == app_arn
+    assert resp["ResourceTagMappingList"][0]["Tags"] == FAKE_TAGS
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