Repository: getmoto/moto

Issue:
Batch: list_jobs missing key metadata
Moto Version: 2.2.12 installed via pip

The jobs returned by `list_jobs` may have many more fields than are currently returned by moto, as seen by the docs here:
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/batch.html#Batch.Client.list_jobs

In particular, I have never observed a Batch job that didn't have `createdAt` in the response to `list_jobs`. However, many of the fields may not be present based on how the job has progressed through Batch at the time of calling `list_jobs`, and I cannot be 100% sure when `createdAt` actually shows up when actually run through AWS. 

However, moto only ever returns `jobId` and `jobName`. Because `describe_jobs` is very slow, I use `list_jobs` wherever possible in my production application. I cannot test with moto currently because I do not allow `createdAt` to be missing from the response of `list_jobs`. 

Though that is a simple change for me, I am wondering if the lack of metadata in moto's implementation of `list_jobs` is intentional, or if there is room for improvement here. 

Here is a `pytest` example showing the lacking metadata on a SUCCEEDED job run through moto.

```
import os
import time

import pytest
import boto3
from moto import mock_s3, mock_batch, mock_iam

@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'

@pytest.fixture(scope='function')
def s3_client(aws_credentials):
    with mock_s3():
        yield boto3.client('s3', region_name='us-east-1')

@pytest.fixture(scope='function')
def batch_client(aws_credentials):
    with mock_batch():
        yield boto3.client('batch', region_name='us-east-1')

@pytest.fixture(scope='function')
def iam_client(aws_credentials):
    with mock_iam():
        yield boto3.client('iam', region_name='us-east-1')

def test_moto(iam_client, batch_client):
    iam_role = iam_client.create_role(
        RoleName='test_batch_client',
        AssumeRolePolicyDocument='string',
    )
    iam_arn = iam_role.get('Role').get('Arn')
    print("iamRoleArn: " + iam_arn)

    created_cenv = batch_client.create_compute_environment(
        computeEnvironmentName='test_batch_ce',
        type='UNMANAGED',
        serviceRole=iam_arn
    )

    compute_environment_arn = created_cenv.get('computeEnvironmentArn')
    print("computeEnvironmentArn: " + compute_environment_arn)

    created_queue = batch_client.create_job_queue(
        jobQueueName='test_batch_queue',
        state='ENABLED',
        priority=1,
        computeEnvironmentOrder=[
            {
                'order': 1,
                'computeEnvironment': compute_environment_arn
            },
        ]
    )
    job_q_arn = created_queue.get('jobQueueArn')
    print("jobQueueArn: " + job_q_arn)

    created_jobdef= batch_client.register_job_definition(
        jobDefinitionName="test_batch_jobdef",
        type='container',
        containerProperties={
            "image": "busybox",
            "vcpus": 1,
            "memory": 128,
            "command": ["sleep", "2"],
        },
    )
    job_definition_arn = created_jobdef.get('jobDefinitionArn')
    print("jobDefinitionArn: " + job_definition_arn)

    #Add job
    batch_client.submit_job(
        jobName=f'batch_test_job',
        jobQueue=job_q_arn,
        jobDefinition=job_definition_arn
    )

    time.sleep(10)
    job_ids = []

    jobs = batch_client.list_jobs(
        jobQueue="test_batch_queue"
    )
    print(jobs)
    for j in jobs['jobSummaryList']:
        job_ids.append(j['jobId'])

    desc_jobs = batch_client.describe_jobs(
        jobs=job_ids
    )

    print(desc_jobs)


```

And the output of the above snippet

```
iamRoleArn: arn:aws:iam::123456789012:role/test_batch_client
computeEnvironmentArn: arn:aws:batch:us-east-1:123456789012:compute-environment/test_batch_ce
jobQueueArn: arn:aws:batch:us-east-1:123456789012:job-queue/test_batch_queue
jobDefinitionArn: arn:aws:batch:us-east-1:123456789012:job-definition/test_batch_jobdef:1
{'ResponseMetadata': {'HTTPStatusCode': 200, 'HTTPHeaders': {'server': 'amazon.com'}, 'RetryAttempts': 0}, 'jobSummaryList': [{'jobId': 'e9fa822f-d38f-4fea-9266-6b555a26bea7', 'jobName': 'batch_test_job'}]}
{'ResponseMetadata': {'HTTPStatusCode': 200, 'HTTPHeaders': {'server': 'amazon.com'}, 'RetryAttempts': 0}, 'jobs': [{'jobName': 'batch_test_job', 'jobId': 'e9fa822f-d38f-4fea-9266-6b555a26bea7', 'jobQueue': 'arn:aws:batch:us-east-1:123456789012:job-queue/test_batch_queue', 'status': 'SUCCEEDED', 'createdAt': 1635792855182, 'startedAt': 1635792855182, 'stoppedAt': 1635792858186, 'dependsOn': [], 'jobDefinition': 'arn:aws:batch:us-east-1:123456789012:job-definition/test_batch_jobdef:1', 'container': {'vcpus': 1, 'memory': 128, 'command': ['sleep', '2'], 'volumes': [], 'environment': [{'name': 'AWS_BATCH_JOB_ID', 'value': 'e9fa822f-d38f-4fea-9266-6b555a26bea7'}], 'readonlyRootFilesystem': False, 'ulimits': [], 'privileged': False, 'logStreamName': 'test_batch_jobdef/default/e9fa822f-d38f-4fea-9266-6b555a26bea7'}}]}
```


Discussion:
Hi @bhayden53, thanks for raising this!
I don't think the lack of output was intentional - usually it's just that whoever implemented it didn't need all of the fields, so didn't bother getting it 100% right.

If you'd like to contribute here, a PR would be very welcome!

Resolving patch:
diff --git a/moto/batch/models.py b/moto/batch/models.py
--- a/moto/batch/models.py
+++ b/moto/batch/models.py
@@ -415,6 +415,7 @@ def __init__(
         self.all_jobs = all_jobs
 
         self.stop = False
+        self.exit_code = None
 
         self.daemon = True
         self.name = "MOTO-BATCH-" + self.job_id
@@ -422,18 +423,28 @@ def __init__(
         self._log_backend = log_backend
         self.log_stream_name = None
 
-    def describe(self):
+    def describe_short(self):
         result = {
-            "jobDefinition": self.job_definition.arn,
             "jobId": self.job_id,
             "jobName": self.job_name,
-            "jobQueue": self.job_queue.arn,
-            "status": self.job_state,
-            "dependsOn": self.depends_on if self.depends_on else [],
             "createdAt": datetime2int_milliseconds(self.job_created_at),
+            "status": self.job_state,
+            "jobDefinition": self.job_definition.arn,
         }
+        if self.job_stopped_reason is not None:
+            result["statusReason"] = self.job_stopped_reason
         if result["status"] not in ["SUBMITTED", "PENDING", "RUNNABLE", "STARTING"]:
             result["startedAt"] = datetime2int_milliseconds(self.job_started_at)
+        if self.job_stopped:
+            result["stoppedAt"] = datetime2int_milliseconds(self.job_stopped_at)
+            if self.exit_code is not None:
+                result["container"] = {"exitCode": self.exit_code}
+        return result
+
+    def describe(self):
+        result = self.describe_short()
+        result["jobQueue"] = self.job_queue.arn
+        result["dependsOn"] = self.depends_on if self.depends_on else []
         if self.job_stopped:
             result["stoppedAt"] = datetime2int_milliseconds(self.job_stopped_at)
             result["container"] = {}
@@ -452,8 +463,6 @@ def describe(self):
                 "environment", []
             )
             result["container"]["logStreamName"] = self.log_stream_name
-        if self.job_stopped_reason is not None:
-            result["statusReason"] = self.job_stopped_reason
         if self.timeout:
             result["timeout"] = self.timeout
         return result
@@ -630,7 +639,8 @@ def run(self):
                 self._log_backend.put_log_events(log_group, stream_name, logs, None)
 
                 result = container.wait() or {}
-                job_failed = self.stop or result.get("StatusCode", 0) > 0
+                self.exit_code = result.get("StatusCode", 0)
+                job_failed = self.stop or self.exit_code > 0
                 self._mark_stopped(success=not job_failed)
 
             except Exception as err:
diff --git a/moto/batch/responses.py b/moto/batch/responses.py
--- a/moto/batch/responses.py
+++ b/moto/batch/responses.py
@@ -276,11 +276,7 @@ def listjobs(self):
         except AWSError as err:
             return err.response()
 
-        result = {
-            "jobSummaryList": [
-                {"jobId": job.job_id, "jobName": job.job_name} for job in jobs
-            ]
-        }
+        result = {"jobSummaryList": [job.describe_short() for job in jobs]}
         return json.dumps(result)
 
     # TerminateJob


Test patch:
diff --git a/tests/test_batch/test_batch_jobs.py b/tests/test_batch/test_batch_jobs.py
--- a/tests/test_batch/test_batch_jobs.py
+++ b/tests/test_batch/test_batch_jobs.py
@@ -160,6 +160,14 @@ def test_list_jobs():
     )
     job_id2 = resp["jobId"]
 
+    all_jobs = batch_client.list_jobs(jobQueue=queue_arn)["jobSummaryList"]
+    all_jobs.should.have.length_of(2)
+    for job in all_jobs:
+        job.should.have.key("createdAt")
+        job.should.have.key("jobDefinition")
+        job.should.have.key("jobName")
+        job.should.have.key("status").equals("STARTING")
+
     batch_client.list_jobs(jobQueue=queue_arn, jobStatus="SUCCEEDED")[
         "jobSummaryList"
     ].should.have.length_of(0)
@@ -168,9 +176,17 @@ def test_list_jobs():
     for job_id in [job_id1, job_id2]:
         _wait_for_job_status(batch_client, job_id, "SUCCEEDED")
 
-    batch_client.list_jobs(jobQueue=queue_arn, jobStatus="SUCCEEDED")[
+    succeeded_jobs = batch_client.list_jobs(jobQueue=queue_arn, jobStatus="SUCCEEDED")[
         "jobSummaryList"
-    ].should.have.length_of(2)
+    ]
+    succeeded_jobs.should.have.length_of(2)
+    for job in succeeded_jobs:
+        job.should.have.key("createdAt")
+        job.should.have.key("jobDefinition")
+        job.should.have.key("jobName")
+        job.should.have.key("status").equals("SUCCEEDED")
+        job.should.have.key("stoppedAt")
+        job.should.have.key("container").should.have.key("exitCode").equals(0)
 
 
 @mock_logs