Repository: oppia/oppia

Issue:
TypeError: Unable to get property 'charAt' of undefined or null reference
This error occurred recently in production
```
TypeError: Unable to get property 'charAt' of undefined or null reference
at getNameOfInteractionRulesService (https://www.oppia.org/build/templates/head/pages/exploration_editor/AngularNameService.e60f402cec807f57f8f1d18ae250dd11.js:1)
at j (https://www.oppia.org/build/templates/head/services/StateTopAnswersStatsService.cb89ccebb730ae6b4fc48acfd3d4eca8.js:1)
at init (https://www.oppia.org/build/templates/head/services/StateTopAnswersStatsService.cb89ccebb730ae6b4fc48acfd3d4eca8.js:1)
```

**Additional context** This occurred from the /create view for explorations.

**General instructions**
There are no specific repro steps available for this bug report. The general procedure to fix server errors should be the following:

* Analyze the code in the file where the error occurred and come up with a hypothesis for the reason.
* Get the logic of the proposed fix validated by an Oppia team member (have this discussion on the issue thread).
* Make a PR that fixes the issue, then close the issue on merging the PR. (If the error reoccurs in production, the issue will be reopened for further investigation.)



Discussion:
I tried looking into this issue. I see one of 2 problems here. 
- Maybe the state_name was invalid, and hence maybe the state obtained was undefined, and hence interaction_id would have been undefined. But I think this would have raised a different error message (unable to get property interaction or something like that).
- The state was successfully loaded, but there was no interaction on the state (happens when you just create the state). But I am not sure if StateTopAnswersStatsService would be initialized/run on such states. 

I think the issue is the latter situation. If so, we could avoid it by adding a check in the getNameOfInteractionRulesService function to make sure "interactionId" is defined before calling functions on it. 
Could this have happened when an interaction got deleted from a state? Maybe that case isn't handled correctly by StateTopAnswersStatsService? If so, we should add a trigger for handling that case correctly.

/cc @brianrodri -- any thoughts?
Just took a look and I can confirm we don't have checks in place for deleted states, which I feel we definitely should. I'll start on a PR to include a check in the backend.
Thanks @brianrodri! Just a small clarification: I meant deleting an interaction from an existing state (which persists before and after the interaction is deleted), not deleting the state itself.

Also, what do you mean by "backend" here? Shouldn't the check be in the frontend, in the StateTopAnswersStatsService file (i.e. it listens to the deletion event and updates the "top answers" accordingly)?
Hmm, well I feel the backend should make sure the state exists as well, right? And IIRC there is FE code to remove top answers from deleted states, I can check to confirm.
No -- not deleted states; I'm talking about the action of deleting interactions from states.
Hmm in that case, no I doubt we have logic for that. I wonder what we should do in such cases... delete the cached stats entirely since it won't be valid anymore..?
Yeah, that sounds right to me.

Resolving patch:
diff --git a/core/controllers/editor.py b/core/controllers/editor.py
--- a/core/controllers/editor.py
+++ b/core/controllers/editor.py
@@ -800,9 +800,15 @@ def get(self, exploration_id):
         except:
             raise self.PageNotFoundException
 
+        top_state_answers = stats_services.get_top_state_answer_stats_multi(
+            exploration_id, current_exploration.states)
+        top_state_interaction_ids = {
+            state_name: current_exploration.states[state_name].interaction.id
+            for state_name in top_state_answers
+        }
         self.render_json({
-            'answers': stats_services.get_top_state_answer_stats_multi(
-                exploration_id, current_exploration.states)
+            'answers': top_state_answers,
+            'interaction_ids': top_state_interaction_ids,
         })
 
 
diff --git a/feconf.py b/feconf.py
--- a/feconf.py
+++ b/feconf.py
@@ -601,6 +601,7 @@ def get_empty_ratings():
 EXPLORATION_METADATA_SEARCH_URL = '/exploration/metadata_search'
 EXPLORATION_PRETESTS_URL_PREFIX = '/pretest_handler'
 EXPLORATION_RIGHTS_PREFIX = '/createhandler/rights'
+EXPLORATION_STATE_ANSWER_STATS_PREFIX = '/createhandler/state_answer_stats'
 EXPLORATION_STATUS_PREFIX = '/createhandler/status'
 EXPLORATION_SUMMARIES_DATA_URL = '/explorationsummarieshandler/data'
 EXPLORATION_URL_PREFIX = '/explore'
diff --git a/main.py b/main.py
--- a/main.py
+++ b/main.py
@@ -483,7 +483,7 @@ def ui_access_wrapper(self, *args, **kwargs):
         r'/createhandler/state_rules_stats/<exploration_id>/<escaped_state_name>',  # pylint: disable=line-too-long
         editor.StateRulesStatsHandler),
     get_redirect_route(
-        r'/createhandler/state_answer_stats/<exploration_id>',
+        r'%s/<exploration_id>' % feconf.EXPLORATION_STATE_ANSWER_STATS_PREFIX,
         editor.StateAnswerStatisticsHandler),
     get_redirect_route(
         r'/createhandler/started_tutorial_event/<exploration_id>',


Test patch:
diff --git a/core/controllers/editor_test.py b/core/controllers/editor_test.py
--- a/core/controllers/editor_test.py
+++ b/core/controllers/editor_test.py
@@ -1889,3 +1889,89 @@ def test_get_user_has_seen_translation_tutorial(self):
         self.assertFalse(response['show_state_translation_tutorial_on_load'])
 
         self.logout()
+
+
+class StateAnswerStatisticsHandlerTests(BaseEditorControllerTests):
+
+    def test_get_invalid_exploration_id(self):
+        with self.login_context(self.OWNER_EMAIL):
+            illegal_id = '@#$%^&*'
+            self.get_json(
+                '%s/%s' % (
+                    feconf.EXPLORATION_STATE_ANSWER_STATS_PREFIX, illegal_id),
+                expected_status_int=404)
+
+    def test_get_missing_exploration_id(self):
+        with self.login_context(self.OWNER_EMAIL):
+            missing_id = '0'
+            self.get_json(
+                '%s/%s' % (
+                    feconf.EXPLORATION_STATE_ANSWER_STATS_PREFIX, missing_id),
+                expected_status_int=404)
+
+    def test_get_returns_empty_values_from_unvisited_exploration(self):
+        with self.login_context(self.OWNER_EMAIL) as owner_id:
+            exp_id = exp_services.get_new_exploration_id()
+            self.save_new_valid_exploration(exp_id, owner_id)
+
+            state_stats = self.get_json(
+                '%s/%s' % (
+                    feconf.EXPLORATION_STATE_ANSWER_STATS_PREFIX, exp_id))
+
+        self.assertEqual(state_stats['answers'], {'Introduction': []})
+
+    def test_get_returns_assigned_interaction_ids_of_exploration_states(self):
+        with self.login_context(self.OWNER_EMAIL) as owner_id:
+            exp_id = exp_services.get_new_exploration_id()
+            self.save_new_linear_exp_with_state_names_and_interactions(
+                exp_id, owner_id,
+                ['A', 'B', 'End'], ['FractionInput', 'TextInput'])
+
+            state_stats = self.get_json(
+                '%s/%s' % (
+                    feconf.EXPLORATION_STATE_ANSWER_STATS_PREFIX, exp_id))
+
+        self.assertEqual(
+            state_stats['interaction_ids'],
+            {'A': 'FractionInput', 'B': 'TextInput', 'End': 'EndExploration'})
+
+    def test_get_returns_recorded_answers_from_exploration(self):
+        with self.login_context(self.OWNER_EMAIL) as owner_id:
+            exp_id = exp_services.get_new_exploration_id()
+            exp = self.save_new_valid_exploration(exp_id, owner_id)
+
+        def mock_get_top_state_answer_stats(exploration_id, state_name):
+            """Returns a fake list of top answers for a particular state.
+
+            Args:
+                exploration_id: str. The exploration ID.
+                state_name: str. The name of the state to fetch answers for.
+
+            Returns:
+                list(dict(str: *)). A list of the top 10 answers, sorted by
+                decreasing frequency.
+            """
+            if (exploration_id, state_name) == (exp_id, exp.init_state_name):
+                return [
+                    {'answer': 'C', 'frequency': 12},
+                    {'answer': 'B', 'frequency': 11},
+                    {'answer': 'A', 'frequency': 10},
+                ]
+
+        swap_get_answers = self.swap(
+            stats_services, 'get_top_state_answer_stats',
+            mock_get_top_state_answer_stats)
+
+        with self.login_context(self.OWNER_EMAIL), swap_get_answers:
+            state_stats = self.get_json(
+                '%s/%s' % (
+                    feconf.EXPLORATION_STATE_ANSWER_STATS_PREFIX, exp_id))
+
+        self.assertEqual(
+            state_stats['answers'], {
+                exp.init_state_name: [
+                    {'answer': 'C', 'frequency': 12},
+                    {'answer': 'B', 'frequency': 11},
+                    {'answer': 'A', 'frequency': 10},
+                ],
+            })
diff --git a/core/tests/test_utils.py b/core/tests/test_utils.py
--- a/core/tests/test_utils.py
+++ b/core/tests/test_utils.py
@@ -514,32 +514,6 @@ def _delete_all_models(self):
         """
         raise NotImplementedError
 
-    def _stash_current_user_env(self):
-        """Stashes the current user-specific env variables for later retrieval.
-
-        Developers: please don't use this method outside this class -- it makes
-        the individual tests harder to follow.
-        """
-        self.stashed_user_env = {  # pylint: disable=attribute-defined-outside-init
-            'USER_EMAIL': os.environ['USER_EMAIL'],
-            'USER_ID': os.environ['USER_ID'],
-            'USER_IS_ADMIN': os.environ['USER_IS_ADMIN']
-        }
-
-    def _restore_stashed_user_env(self):
-        """Restores a stashed set of user-specific env variables.
-
-        Developers: please don't use this method outside this class -- it makes
-        the individual tests harder to follow.
-        """
-        if not self.stashed_user_env:
-            raise Exception('No stashed user env to restore.')
-
-        for key in self.stashed_user_env:
-            os.environ[key] = self.stashed_user_env[key]
-
-        self.stashed_user_env = None  # pylint: disable=attribute-defined-outside-init
-
     def login(self, email, is_super_admin=False):
         """Sets the environment variables to simulate a login.
 
@@ -884,21 +858,17 @@ def set_config_property(self, config_obj, new_config_value):
         """Sets a given configuration object's value to the new value specified
         using a POST request.
         """
-        self._stash_current_user_env()
-
-        self.login('tmpsuperadmin@example.com', is_super_admin=True)
-        response = self.get_html_response('/admin')
-        csrf_token = self.get_csrf_token_from_response(response)
-        self.post_json(
-            '/adminhandler', {
-                'action': 'save_config_properties',
-                'new_config_property_values': {
-                    config_obj.name: new_config_value,
-                }
-            }, csrf_token=csrf_token)
-        self.logout()
-
-        self._restore_stashed_user_env()
+        with self.login_context('tmpsuperadmin@example.com',
+                                is_super_admin=True):
+            response = self.get_html_response('/admin')
+            csrf_token = self.get_csrf_token_from_response(response)
+            self.post_json(
+                '/adminhandler', {
+                    'action': 'save_config_properties',
+                    'new_config_property_values': {
+                        config_obj.name: new_config_value,
+                    }
+                }, csrf_token=csrf_token)
 
     def set_user_role(self, username, user_role):
         """Sets the given role for this user.
@@ -907,19 +877,15 @@ def set_user_role(self, username, user_role):
             username: str. Username of the given user.
             user_role: str. Role of the given user.
         """
-        self._stash_current_user_env()
-
-        self.login('tmpsuperadmin@example.com', is_super_admin=True)
-        response = self.get_html_response('/admin')
-        csrf_token = self.get_csrf_token_from_response(response)
-        self.post_json(
-            '/adminrolehandler', {
-                'username': username,
-                'role': user_role
-            }, csrf_token=csrf_token)
-        self.logout()
-
-        self._restore_stashed_user_env()
+        with self.login_context('tmpsuperadmin@example.com',
+                                is_super_admin=True):
+            response = self.get_html_response('/admin')
+            csrf_token = self.get_csrf_token_from_response(response)
+            self.post_json(
+                '/adminrolehandler', {
+                    'username': username,
+                    'role': user_role
+                }, csrf_token=csrf_token)
 
     def set_admins(self, admin_usernames):
         """Sets role of given users as ADMIN.
@@ -1685,6 +1651,30 @@ def swap(self, obj, attr, newvalue):
         finally:
             setattr(obj, attr, original)
 
+    @contextlib.contextmanager
+    def login_context(self, email, is_super_admin=False):
+        """Log in with the given email under the context of a 'with' statement.
+
+        Args:
+            email: str. An email associated to a user account.
+            is_super_admin: bool. Whether the user is a super admin.
+
+        Yields:
+            str. The id of the user associated to the given email, who is now
+            'logged in'.
+        """
+        initial_user_env = {
+            'USER_EMAIL': os.environ['USER_EMAIL'],
+            'USER_ID': os.environ['USER_ID'],
+            'USER_IS_ADMIN': os.environ['USER_IS_ADMIN']
+        }
+        self.login(email, is_super_admin=is_super_admin)
+        try:
+            yield self.get_user_id_from_email(email)
+        finally:
+            self.logout()
+            os.environ.update(initial_user_env)
+
 
 class AppEngineTestBase(TestBase):
     """Base class for tests requiring App Engine services."""