Repository: oppia/oppia

Issue:
[BUG]: Android controller endpoint doesn't seem to support fetching multiple versions of structures
### Describe the bug

The Android asset fetching controller (``/android_data`` endpoint) doesn't seem to correctly return multiple structure versions when requested.

### Steps To Reproduce

Using curl:

```sh
curl "https://<base_oppia_url>/android_data?activity_type=subtopic&activities_data=%5B%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A1%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A2%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A3%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A4%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A5%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A6%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A7%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A8%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A9%7D%5D" -H "X-ApiKey: <redacted_api_key>"
```

The above request is the following JSON string encoded:

```
"[{\"id\":\"C4fqwrvqWpRm-4\",\"version\":1},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":2},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":3},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":4},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":5},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":6},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":7},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":8},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":9}]"
```

Or, in formatted JSON:

```json
[
  {
    "id":"C4fqwrvqWpRm-4",
    "version":1
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":2
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":3
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":4
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":5
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":6
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":7
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":8
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":9
  }
]
```

As shown, the request is trying to fetch versions 1-9 of subtopic ``C4fqwrvqWpRm-4`` (subtopic 4 of topic ``C4fqwrvqWpRm``).

However, the above curl request returns the following from the server:

```json
{
  "C4fqwrvqWpRm-4":{
    "id":"C4fqwrvqWpRm-4",
    "topic_id":"C4fqwrvqWpRm",
    <pagedata...>
    "page_contents_schema_version":4,
    "language_code":"en",
    "version":9
  }
}
```

As seen, the response is only returning version 9.

### Expected Behavior

The response should return all versions of the requested subtopic, not just the latest (or potentially last--I didn't verify this). I have verified that these other versions both exist on the server that was tested (backup migration server) and that they fetch through the standard subtopic controller.

### Screenshots/Videos

_No response_

### What device are you using?

Desktop

### Operating System

Linux

### What browsers are you seeing the problem on?

Other

### Browser version

N/A

### Additional context

_No response_
[BUG]: Android controller endpoint doesn't seem to support fetching multiple versions of structures
### Describe the bug

The Android asset fetching controller (``/android_data`` endpoint) doesn't seem to correctly return multiple structure versions when requested.

### Steps To Reproduce

Using curl:

```sh
curl "https://<base_oppia_url>/android_data?activity_type=subtopic&activities_data=%5B%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A1%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A2%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A3%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A4%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A5%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A6%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A7%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A8%7D%2C%7B%22id%22%3A%22C4fqwrvqWpRm-4%22%2C%22version%22%3A9%7D%5D" -H "X-ApiKey: <redacted_api_key>"
```

The above request is the following JSON string encoded:

```
"[{\"id\":\"C4fqwrvqWpRm-4\",\"version\":1},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":2},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":3},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":4},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":5},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":6},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":7},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":8},{\"id\":\"C4fqwrvqWpRm-4\",\"version\":9}]"
```

Or, in formatted JSON:

```json
[
  {
    "id":"C4fqwrvqWpRm-4",
    "version":1
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":2
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":3
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":4
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":5
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":6
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":7
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":8
  },
  {
    "id":"C4fqwrvqWpRm-4",
    "version":9
  }
]
```

As shown, the request is trying to fetch versions 1-9 of subtopic ``C4fqwrvqWpRm-4`` (subtopic 4 of topic ``C4fqwrvqWpRm``).

However, the above curl request returns the following from the server:

```json
{
  "C4fqwrvqWpRm-4":{
    "id":"C4fqwrvqWpRm-4",
    "topic_id":"C4fqwrvqWpRm",
    <pagedata...>
    "page_contents_schema_version":4,
    "language_code":"en",
    "version":9
  }
}
```

As seen, the response is only returning version 9.

### Expected Behavior

The response should return all versions of the requested subtopic, not just the latest (or potentially last--I didn't verify this). I have verified that these other versions both exist on the server that was tested (backup migration server) and that they fetch through the standard subtopic controller.

### Screenshots/Videos

_No response_

### What device are you using?

Desktop

### Operating System

Linux

### What browsers are you seeing the problem on?

Other

### Browser version

N/A

### Additional context

_No response_
E2E Flake: Creator Dashboard radio is not visible.
Example: 
* https://github.com/oppia/oppia/actions/runs/3481040007/jobs/5821616105#step:14:381
* 2022-11-25: https://github.com/oppia/oppia/actions/runs/3545181217/jobs/5953110823
* 2023-02-20: https://github.com/oppia/oppia/actions/runs/4221370533/jobs/7329220348#step:11:379
* 2023-06-2: https://github.com/oppia/oppia/actions/runs/5155472159/jobs/9286021438
* https://github.com/oppia/oppia/actions/runs/5153206494/jobs/9280985045

```
Suite started : 
   �� unknown spec file
 Error reporting
ERROR:root:Frontend error: 
Uncaught (in promise): S: {"headers":{"normalizedNames":{},"lazyUpdate":null},"status":401,"statusText":"Unauthorized","url":"http://localhost:9001/preferenceshandler/data","ok":false,"name":"HttpErrorResponse","message":"Http failure response for http://localhost:9001/preferenceshandler/data: 401 Unauthorized","error":{"error":"You must be logged in to access this resource.","status_code":401}}

    at URL: http://localhost:9001/preferences
ERROR:root:Frontend error: 
Uncaught (in promise): S: {"headers":{"normalizedNames":{},"lazyUpdate":null},"status":401,"statusText":"Unauthorized","url":"http://localhost:9001/preferenceshandler/data","ok":false,"name":"HttpErrorResponse","message":"Http failure response for http://localhost:9001/preferenceshandler/data: 401 Unauthorized","error":{"error":"You must be logged in to access this resource.","status_code":401}}

    at URL: http://localhost:9001/preferences
[0-0] Error in "Error reporting should report a client error to the backend"
Error: Creator Dashboard radio is not visible.
Error
    at Object.visibilityOf (/home/runner/work/oppia/oppia/core/tests/webdriverio_utils/waitFor.js:123:39)
    at Object.click (/home/runner/work/oppia/oppia/core/tests/webdriverio_utils/action.js:34:17)
    at UserContext.<anonymous> (/home/runner/work/oppia/oppia/core/tests/webdriverio_desktop/navigation.js:263:18)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
    at async Object.visibilityOf (/home/runner/work/oppia/oppia/core/tests/webdriverio_utils/waitFor.js:121:3)
    at async Object.click (/home/runner/work/oppia/oppia/core/tests/webdriverio_utils/action.js:34:3)
    at async UserContext.<anonymous> (/home/runner/work/oppia/oppia/core/tests/webdriverio_desktop/navigation.js:263:5)
    ��� should report a client error to the backend �� [ unknown spec file ]
[0-0] FAILED in chrome - /core/tests/webdriverio_desktop/navigation.js
```


Discussion:
I confirmed, through manual testing, that only the dict corresponding to the last item in the list is returned.

Here is the reason: https://github.com/oppia/oppia/blob/develop/core/controllers/android.py#L184

The handler seems to be assuming that activity_data versions don't repeat for the same ID. Therefore it is overwriting activities[activity_data['id']] with a new version each time. Here, activity_data['id'] is the same for all versions you're requesting, so that's why the return dict only has one element.

@BenHenning This should be fairly easy to fix, though I would advise not gating on it. What do you want the format of the return dict to be (here and in the other cases)?
Nice catch @seanlip.

I think that, in order to make the API more predictable, we should do away with the dict-like response and instead return a list of 1:1 responses for the requested ID/version pairs, in the same order as the request. Failures can be represented using null.

For the request structure:

```json
[
  {
    "id": "<value>",
    "version": <version>
  },
  ...
]
```

We should have a corresponding response structure:

```json
[
  {
    "id": "<value>",
    "version": <version>,
    "payload": {...}
  },
  ...
]
```

If a particular ID/version request cannot be fulfilled, the payload should be 'null'. The above format _would_ allow for duplicate requests, but I think we should detect that and fail with a 400 in that case (since the script should make an effort not to request duplicates).

Do you think the above would work?
Yup this structure seems fine to me. Thanks!
I confirmed, through manual testing, that only the dict corresponding to the last item in the list is returned.

Here is the reason: https://github.com/oppia/oppia/blob/develop/core/controllers/android.py#L184

The handler seems to be assuming that activity_data versions don't repeat for the same ID. Therefore it is overwriting activities[activity_data['id']] with a new version each time. Here, activity_data['id'] is the same for all versions you're requesting, so that's why the return dict only has one element.

@BenHenning This should be fairly easy to fix, though I would advise not gating on it. What do you want the format of the return dict to be (here and in the other cases)?
Nice catch @seanlip.

I think that, in order to make the API more predictable, we should do away with the dict-like response and instead return a list of 1:1 responses for the requested ID/version pairs, in the same order as the request. Failures can be represented using null.

For the request structure:

```json
[
  {
    "id": "<value>",
    "version": <version>
  },
  ...
]
```

We should have a corresponding response structure:

```json
[
  {
    "id": "<value>",
    "version": <version>,
    "payload": {...}
  },
  ...
]
```

If a particular ID/version request cannot be fulfilled, the payload should be 'null'. The above format _would_ allow for duplicate requests, but I think we should detect that and fail with a 400 in that case (since the script should make an effort not to request duplicates).

Do you think the above would work?
Yup this structure seems fine to me. Thanks!


Resolving patch:
diff --git a/core/controllers/android.py b/core/controllers/android.py
--- a/core/controllers/android.py
+++ b/core/controllers/android.py
@@ -24,8 +24,6 @@
 from core.domain import classroom_config_domain
 from core.domain import classroom_config_services
 from core.domain import classroom_domain
-from core.domain import classroom_services
-from core.domain import config_domain
 from core.domain import exp_domain
 from core.domain import exp_fetchers
 from core.domain import skill_domain
@@ -36,7 +34,6 @@
 from core.domain import subtopic_page_services
 from core.domain import topic_domain
 from core.domain import topic_fetchers
-from core.domain import translation_domain
 from core.domain import translation_fetchers
 
 from typing import Dict, List, Optional, TypedDict, Union
@@ -82,21 +79,50 @@ def post(self) -> None:
         })
 
 
-class ActivityDataDict(TypedDict):
-    """Dict representation of items in activites_data."""
+class ActivityDataRequestDict(TypedDict):
+    """Dict representation of items in activities_data."""
 
     id: str
     version: Optional[int]
     language_code: Optional[str]
 
 
+class _ActivityDataResponseDictRequiredFields(TypedDict):
+    """Required fields for items returned in the activities response list.
+
+    Note: See https://stackoverflow.com/a/74843909. NotRequired isn't available
+    yet for us to use here.
+    """
+
+    id: str
+    payload: Union[
+        exp_domain.ExplorationDict,
+        story_domain.StoryDict,
+        skill_domain.SkillDict,
+        subtopic_page_domain.SubtopicPageDict,
+        classroom_config_domain.ClassroomDict,
+        topic_domain.TopicDict,
+        Dict[str, feconf.TranslatedContentDict],
+        classroom_domain.ClassroomDict,
+        None
+    ]
+
+
+class ActivityDataResponseDict(
+        _ActivityDataResponseDictRequiredFields, total=False):
+    """Dict representation of items returned in the activities response list."""
+
+    version: Optional[int]
+    language_code: str
+
+
 class AndroidActivityHandlerHandlerNormalizedRequestDict(TypedDict):
     """Dict representation of AndroidActivityHandler's normalized_request
     dictionary.
     """
 
     activity_type: str
-    activities_data: List[ActivityDataDict]
+    activities_data: List[ActivityDataRequestDict]
 
 
 class AndroidActivityHandler(base.BaseHandler[
@@ -142,70 +168,82 @@ def get(self) -> None:
         assert self.normalized_request is not None
         activities_data = self.normalized_request['activities_data']
         activity_type = self.normalized_request['activity_type']
-        activities: Dict[str, Union[
-            exp_domain.Exploration,
-            story_domain.Story,
-            skill_domain.Skill,
-            subtopic_page_domain.SubtopicPage,
-            classroom_config_domain.Classroom,
-            topic_domain.Topic,
-            translation_domain.EntityTranslation,
-            classroom_domain.Classroom,
-            None
-        ]] = {}
+        activities: List[ActivityDataResponseDict] = []
+
+        hashed_activities_data = [
+            tuple(sorted((k, v) for k, v in activity_data.items()))
+            for activity_data in activities_data]
+        if len(set(hashed_activities_data)) != len(hashed_activities_data):
+            raise self.InvalidInputException(
+                'Entries in activities_data should be unique'
+            )
 
         if activity_type == constants.ACTIVITY_TYPE_EXPLORATION:
-            activities = {
-                activity_data['id']: exp_fetchers.get_exploration_by_id(
+            for activity_data in activities_data:
+                exploration = exp_fetchers.get_exploration_by_id(
                     activity_data['id'],
                     strict=False,
-                    version=activity_data.get('version')
-                ) for activity_data in activities_data
-            }
+                    version=activity_data.get('version'))
+                activities.append({
+                    'id': activity_data['id'],
+                    'version': activity_data.get('version'),
+                    'payload': (
+                        exploration.to_dict() if exploration is not None
+                        else None)
+                })
         elif activity_type == constants.ACTIVITY_TYPE_STORY:
-            activities = {
-                activity_data['id']: story_fetchers.get_story_by_id(
+            for activity_data in activities_data:
+                story = story_fetchers.get_story_by_id(
                     activity_data['id'],
                     strict=False,
-                    version=activity_data.get('version')
-                ) for activity_data in activities_data
-            }
+                    version=activity_data.get('version'))
+                activities.append({
+                    'id': activity_data['id'],
+                    'version': activity_data.get('version'),
+                    'payload': (
+                        story.to_dict() if story is not None else None)
+                })
         elif activity_type == constants.ACTIVITY_TYPE_SKILL:
-            activities = {
-                activity_data['id']: skill_fetchers.get_skill_by_id(
+            for activity_data in activities_data:
+                skill = skill_fetchers.get_skill_by_id(
                     activity_data['id'],
                     strict=False,
-                    version=activity_data.get('version')
-                ) for activity_data in activities_data
-            }
+                    version=activity_data.get('version'))
+                activities.append({
+                    'id': activity_data['id'],
+                    'version': activity_data.get('version'),
+                    'payload': (
+                        skill.to_dict() if skill is not None else None)
+                })
         elif activity_type == constants.ACTIVITY_TYPE_SUBTOPIC:
             for activity_data in activities_data:
                 topic_id, subtopic_page_id = activity_data['id'].split('-')
-                activities[activity_data['id']] = (
-                    subtopic_page_services.get_subtopic_page_by_id(
-                        topic_id,
-                        int(subtopic_page_id),
-                        strict=False,
-                        version=activity_data.get('version')
-                    )
+                subtopic_page = subtopic_page_services.get_subtopic_page_by_id(
+                    topic_id,
+                    int(subtopic_page_id),
+                    strict=False,
+                    version=activity_data.get('version')
                 )
+                activities.append({
+                    'id': activity_data['id'],
+                    'version': activity_data.get('version'),
+                    'payload': (
+                        subtopic_page.to_dict() if subtopic_page is not None
+                        else None)
+                })
         elif activity_type == constants.ACTIVITY_TYPE_CLASSROOM:
             for activity_data in activities_data:
                 if activity_data.get('version') is not None:
                     raise self.InvalidInputException(
                         'Version cannot be specified for classroom')
-                matching_classroom_fragment = [
-                    classroom['url_fragment']
-                    for classroom in config_domain.CLASSROOM_PAGES_DATA.value
-                    if classroom['name'] == activity_data['id']
-                ][0]
-                activities[activity_data['id']] = (
+                classroom = (
                     classroom_config_services.get_classroom_by_url_fragment(
-                        activity_data['id']
-                    ) or classroom_services.get_classroom_by_url_fragment(
-                        matching_classroom_fragment
-                    )
-                )
+                        activity_data['id']))
+                activities.append({
+                    'id': activity_data['id'],
+                    'payload': (
+                        classroom.to_dict() if classroom is not None else None)
+                })
         elif activity_type == constants.ACTIVITY_TYPE_EXPLORATION_TRANSLATIONS:
             entity_type = feconf.TranslatableEntityType(
                 feconf.ENTITY_TYPE_EXPLORATION)
@@ -217,24 +255,32 @@ def get(self) -> None:
                         'Version and language code must be specified '
                         'for translation'
                     )
-                activities[activity_data['id']] = (
-                    translation_fetchers.get_entity_translation(
-                        entity_type,
-                        activity_data['id'],
-                        version,
-                        language_code
-                    )
+                translation = translation_fetchers.get_entity_translation(
+                    entity_type,
+                    activity_data['id'],
+                    version,
+                    language_code
                 )
+                activities.append({
+                    'id': activity_data['id'],
+                    'version': version,
+                    'language_code': language_code,
+                    'payload': (
+                        translation.to_dict()['translations']
+                        if translation is not None
+                        else None)
+                })
         else:
-            activities = {
-                activity_data['id']: topic_fetchers.get_topic_by_id(
+            for activity_data in activities_data:
+                topic = topic_fetchers.get_topic_by_id(
                     activity_data['id'],
                     strict=False,
-                    version=activity_data.get('version')
-                ) for activity_data in activities_data
-            }
+                    version=activity_data.get('version'))
+                activities.append({
+                    'id': activity_data['id'],
+                    'version': activity_data.get('version'),
+                    'payload': (
+                        topic.to_dict() if topic is not None else None)
+                })
 
-        self.render_json({
-            id: activity.to_dict() if activity is not None else None
-            for id, activity in activities.items()
-        })
+        self.render_json(activities)


Test patch:
diff --git a/core/controllers/android_test.py b/core/controllers/android_test.py
--- a/core/controllers/android_test.py
+++ b/core/controllers/android_test.py
@@ -108,7 +108,7 @@ def test_get_with_wrong_api_key_returns_error(self) -> None:
                 expected_status_int=401
             )
 
-    def test_get_non_existent_activity_returns_error(self) -> None:
+    def test_get_non_existent_activity_returns_null_payload(self) -> None:
         with self.secrets_swap:
             self.assertEqual(
                 self.get_json(
@@ -117,7 +117,7 @@ def test_get_non_existent_activity_returns_error(self) -> None:
                     headers={'X-ApiKey': 'secret'},
                     expected_status_int=200
                 ),
-                {'story_id': None}
+                [{'id': 'story_id', 'version': 1, 'payload': None}]
             )
 
     def test_get_exploration_returns_correct_json(self) -> None:
@@ -130,7 +130,11 @@ def test_get_exploration_returns_correct_json(self) -> None:
                     headers={'X-ApiKey': 'secret'},
                     expected_status_int=200
                 ),
-                {'exp_id': exploration.to_dict()}
+                [{
+                    'id': 'exp_id',
+                    'version': 1,
+                    'payload': exploration.to_dict()
+                }]
             )
 
     def test_get_different_versions_of_exploration_returns_correct_json(
@@ -159,7 +163,11 @@ def test_get_different_versions_of_exploration_returns_correct_json(
                     headers={'X-ApiKey': 'secret'},
                     expected_status_int=200
                 ),
-                {'exp_id': exploration.to_dict()}
+                [{
+                    'id': 'exp_id',
+                    'version': 1,
+                    'payload': exploration.to_dict()
+                }]
             )
             self.assertEqual(
                 self.get_json(
@@ -168,7 +176,124 @@ def test_get_different_versions_of_exploration_returns_correct_json(
                     headers={'X-ApiKey': 'secret'},
                     expected_status_int=200
                 ),
-                {'exp_id': new_exploration.to_dict()}
+                [{
+                    'id': 'exp_id',
+                    'version': 2,
+                    'payload': new_exploration.to_dict()
+                }]
+            )
+
+    def test_get_multiple_versions_at_a_time_returns_correct_json(self) -> None:
+        exploration = self.save_new_default_exploration('exp_id', 'owner_id')
+        exp_services.update_exploration(
+            'owner_id',
+            'exp_id',
+            [
+                exp_domain.ExplorationChange({
+                    'cmd': 'edit_exploration_property',
+                    'property_name': 'objective',
+                    'new_value': 'new objective'
+                })
+            ],
+            'change objective'
+        )
+        new_exploration = exp_fetchers.get_exploration_by_id('exp_id')
+
+        with self.secrets_swap:
+            # Try fetching two versions at once, in either order.
+            self.assertEqual(
+                self.get_json(
+                    '/android_data?activity_type=exploration&'
+                    'activities_data=[{"id": "exp_id", "version": 2}, '
+                    '{"id": "exp_id", "version": 1}]',
+                    headers={'X-ApiKey': 'secret'},
+                    expected_status_int=200
+                ),
+                [{
+                    'id': 'exp_id',
+                    'version': 2,
+                    'payload': new_exploration.to_dict()
+                }, {
+                    'id': 'exp_id',
+                    'version': 1,
+                    'payload': exploration.to_dict()
+                }]
+            )
+
+            self.assertEqual(
+                self.get_json(
+                    '/android_data?activity_type=exploration&'
+                    'activities_data=[{"id": "exp_id", "version": 1}, '
+                    '{"id": "exp_id", "version": 2}]',
+                    headers={'X-ApiKey': 'secret'},
+                    expected_status_int=200
+                ),
+                [{
+                    'id': 'exp_id',
+                    'version': 1,
+                    'payload': exploration.to_dict()
+                }, {
+                    'id': 'exp_id',
+                    'version': 2,
+                    'payload': new_exploration.to_dict()
+                }]
+            )
+
+    def test_get_with_invalid_versions_returns_correct_json(self) -> None:
+        exploration = self.save_new_default_exploration('exp_id', 'owner_id')
+
+        with self.secrets_swap:
+            # Note that version 3 does not exist.
+            self.assertEqual(
+                self.get_json(
+                    '/android_data?activity_type=exploration&'
+                    'activities_data=[{"id": "exp_id", "version": 3}, '
+                    '{"id": "exp_id", "version": 1}]',
+                    headers={'X-ApiKey': 'secret'},
+                    expected_status_int=200
+                ),
+                [{
+                    'id': 'exp_id',
+                    'version': 3,
+                    'payload': None
+                }, {
+                    'id': 'exp_id',
+                    'version': 1,
+                    'payload': exploration.to_dict()
+                }]
+            )
+
+            # For completeness, try the opposite order as well.
+            self.assertEqual(
+                self.get_json(
+                    '/android_data?activity_type=exploration&'
+                    'activities_data=[{"id": "exp_id", "version": 1}, '
+                    '{"id": "exp_id", "version": 3}]',
+                    headers={'X-ApiKey': 'secret'},
+                    expected_status_int=200
+                ),
+                [{
+                    'id': 'exp_id',
+                    'version': 1,
+                    'payload': exploration.to_dict()
+                }, {
+                    'id': 'exp_id',
+                    'version': 3,
+                    'payload': None
+                }]
+            )
+
+    def test_get_with_duplicates_is_rejected(self) -> None:
+        with self.secrets_swap:
+            self.assertEqual(
+                self.get_json(
+                    '/android_data?activity_type=exploration&'
+                    'activities_data=[{"id": "exp_id", "version": 1}, '
+                    '{"id": "exp_id", "version": 1}]',
+                    headers={'X-ApiKey': 'secret'},
+                    expected_status_int=400
+                )['error'],
+                'Entries in activities_data should be unique'
             )
 
     def test_get_story_returns_correct_json(self) -> None:
@@ -181,7 +306,11 @@ def test_get_story_returns_correct_json(self) -> None:
                     headers={'X-ApiKey': 'secret'},
                     expected_status_int=200
                 ),
-                {'story_id': story.to_dict()}
+                [{
+                    'id': 'story_id',
+                    'version': 1,
+                    'payload': story.to_dict()
+                }]
             )
 
     def test_get_skill_returns_correct_json(self) -> None:
@@ -194,7 +323,11 @@ def test_get_skill_returns_correct_json(self) -> None:
                     headers={'X-ApiKey': 'secret'},
                     expected_status_int=200
                 ),
-                {'skill_id': skill.to_dict()}
+                [{
+                    'id': 'skill_id',
+                    'version': 1,
+                    'payload': skill.to_dict()
+                }]
             )
 
     def test_get_subtopic_returns_correct_json(self) -> None:
@@ -207,7 +340,11 @@ def test_get_subtopic_returns_correct_json(self) -> None:
                     headers={'X-ApiKey': 'secret'},
                     expected_status_int=200
                 ),
-                {'topic_id-1': subtopic.to_dict()}
+                [{
+                    'id': 'topic_id-1',
+                    'version': 1,
+                    'payload': subtopic.to_dict()
+                }]
             )
 
     def test_get_classroom_returns_correct_json(self) -> None:
@@ -234,7 +371,10 @@ def test_get_classroom_returns_correct_json(self) -> None:
                     headers={'X-ApiKey': 'secret'},
                     expected_status_int=200
                 ),
-                {'math': classroom.to_dict()}
+                [{
+                    'id': 'math',
+                    'payload': classroom.to_dict()
+                }]
             )
 
     def test_get_classroom_with_version_returns_error(self) -> None:
@@ -295,7 +435,13 @@ def test_get_exploration_translation_without_version_fails(self) -> None:
     def test_get_exploration_translation_returns_correct_json(self) -> None:
         translation_model = (
             translation_models.EntityTranslationsModel.create_new(
-                'exploration', 'translation_id', 1, 'es', {}))
+                'exploration', 'translation_id', 1, 'es', {
+                    'content_id_123': {
+                        'content_value': 'Hello world!',
+                        'needs_update': False,
+                        'content_format': 'html'
+                    }
+                }))
         translation_model.update_timestamps()
         translation_model.put()
         with self.secrets_swap:
@@ -310,15 +456,18 @@ def test_get_exploration_translation_returns_correct_json(self) -> None:
                     headers={'X-ApiKey': 'secret'},
                     expected_status_int=200
                 ),
-                {
-                    'translation_id': {
-                        'entity_id': 'translation_id',
-                        'entity_type': 'exploration',
-                        'entity_version': 1,
-                        'language_code': 'es',
-                        'translations': {}
+                [{
+                    'id': 'translation_id',
+                    'language_code': 'es',
+                    'version': 1,
+                    'payload': {
+                        'content_id_123': {
+                            'content_value': 'Hello world!',
+                            'needs_update': False,
+                            'content_format': 'html'
+                        }
                     }
-                }
+                }]
             )
 
     def test_get_exploration_translation_with_zero_items_returns_correct_json(
@@ -332,7 +481,7 @@ def test_get_exploration_translation_with_zero_items_returns_correct_json(
                     headers={'X-ApiKey': 'secret'},
                     expected_status_int=200
                 ),
-                {}
+                []
             )
 
     def test_get_topic_returns_correct_json(self) -> None:
@@ -345,5 +494,9 @@ def test_get_topic_returns_correct_json(self) -> None:
                     headers={'X-ApiKey': 'secret'},
                     expected_status_int=200
                 ),
-                {'topic_id': topic.to_dict()}
+                [{
+                    'id': 'topic_id',
+                    'version': 1,
+                    'payload': topic.to_dict()
+                }]
             )
diff --git a/core/tests/webdriverio_utils/PreferencesPage.js b/core/tests/webdriverio_utils/PreferencesPage.js
--- a/core/tests/webdriverio_utils/PreferencesPage.js
+++ b/core/tests/webdriverio_utils/PreferencesPage.js
@@ -58,6 +58,8 @@ var PreferencesPage = function() {
   this.get = async function() {
     await browser.url(USER_PREFERENCES_URL);
     await waitFor.pageToFullyLoad();
+    // Click on a neutral element.
+    await action.click('Preferences page header', pageHeader);
   };
 
   this.expectUploadError = async function() {