core/domain/exp_services_test.py

Summary

Maintainability
F
1 mo
Test Coverage
# coding: utf-8
#
# Copyright 2014 The Oppia Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS-IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Unit tests for core.domain.exp_services."""

from __future__ import annotations

import datetime
import logging
import os
import re
import zipfile

from core import feconf
from core import utils
from core.constants import constants
from core.domain import change_domain
from core.domain import classifier_services
from core.domain import exp_domain
from core.domain import exp_fetchers
from core.domain import exp_services
from core.domain import feedback_services
from core.domain import fs_services
from core.domain import opportunity_services
from core.domain import param_domain
from core.domain import rating_services
from core.domain import rights_domain
from core.domain import rights_manager
from core.domain import search_services
from core.domain import state_domain
from core.domain import stats_services
from core.domain import story_domain
from core.domain import story_services
from core.domain import subscription_services
from core.domain import suggestion_services
from core.domain import topic_fetchers
from core.domain import topic_services
from core.domain import translation_domain
from core.domain import translation_fetchers
from core.domain import translation_services
from core.domain import user_services
from core.platform import models
from core.tests import test_utils
from extensions import domain

from typing import (
    Dict, Final, List, Optional, Sequence, Tuple, Type, Union, cast
)

MYPY = False
if MYPY:  # pragma: no cover
    from mypy_imports import exp_models
    from mypy_imports import feedback_models
    from mypy_imports import opportunity_models
    from mypy_imports import recommendations_models
    from mypy_imports import stats_models
    from mypy_imports import suggestion_models
    from mypy_imports import user_models

(
    feedback_models,
    exp_models,
    opportunity_models,
    recommendations_models,
    translation_models,
    stats_models,
    suggestion_models,
    user_models
) = models.Registry.import_models([
    models.Names.FEEDBACK,
    models.Names.EXPLORATION,
    models.Names.OPPORTUNITY,
    models.Names.RECOMMENDATIONS,
    models.Names.TRANSLATION,
    models.Names.STATISTICS,
    models.Names.SUGGESTION,
    models.Names.USER
])

search_services = models.Registry.import_search_services()

# TODO(msl): Test ExpSummaryModel changes if explorations are updated,
# reverted, deleted, created, rights changed.


TestCustArgDictType = Dict[
    str,
    Dict[str, Union[bool, Dict[str, Union[str, List[Dict[str, Union[str, Dict[
        str, Union[str, List[List[float]]]]
    ]]]]]]]
]


def count_at_least_editable_exploration_summaries(user_id: str) -> int:
    """Counts exp summaries that are at least editable by the given user.

    Args:
        user_id: unicode. The id of the given user.

    Returns:
        int. The number of exploration summaries that are at least editable
        by the given user.
    """
    return len(exp_fetchers.get_exploration_summaries_from_models(
        exp_models.ExpSummaryModel.get_at_least_editable(
            user_id=user_id)))


class ExplorationServicesUnitTests(test_utils.GenericTestBase):
    """Test the exploration services module."""

    EXP_0_ID: Final = 'An_exploration_0_id'
    EXP_1_ID: Final = 'An_exploration_1_id'
    EXP_2_ID: Final = 'An_exploration_2_id'

    def setUp(self) -> None:
        """Before each individual test, create a dummy exploration."""
        super().setUp()

        self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME)
        self.signup(self.EDITOR_EMAIL, self.EDITOR_USERNAME)
        self.signup(self.VOICE_ARTIST_EMAIL, self.VOICE_ARTIST_USERNAME)
        self.signup(self.VIEWER_EMAIL, self.VIEWER_USERNAME)
        self.signup(self.CURRICULUM_ADMIN_EMAIL, self.CURRICULUM_ADMIN_USERNAME)

        self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)
        self.editor_id = self.get_user_id_from_email(self.EDITOR_EMAIL)
        self.voice_artist_id = self.get_user_id_from_email(
            self.VOICE_ARTIST_EMAIL)
        self.viewer_id = self.get_user_id_from_email(self.VIEWER_EMAIL)
        self.user_id_admin = (
            self.get_user_id_from_email(self.CURRICULUM_ADMIN_EMAIL))

        self.owner = user_services.get_user_actions_info(self.owner_id)

        self.set_curriculum_admins([self.CURRICULUM_ADMIN_USERNAME])
        self.admin = user_services.get_user_actions_info(self.user_id_admin)


class ExplorationRevertClassifierTests(ExplorationServicesUnitTests):
    """Test that classifier models are correctly mapped when an exploration
    is reverted.
    """

    def test_raises_key_error_for_invalid_id(self) -> None:
        exploration = exp_domain.Exploration.create_default_exploration(
            'tes_exp_id', title='some title', category='Algebra',
            language_code=constants.DEFAULT_LANGUAGE_CODE
        )
        exploration.objective = 'An objective'
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        self.set_interaction_for_state(
            exploration.states[exploration.init_state_name], 'NumericInput',
            content_id_generator
        )
        exp_services.save_new_exploration(self.owner_id, exploration)

        interaction_answer_groups = [{
            'rule_specs': [{
                    'inputs': {
                        'x': 60
                    },
                    'rule_type': 'IsLessThanOrEqualTo'
            }],
            'outcome': {
                'dest': feconf.DEFAULT_INIT_STATE_NAME,
                'dest_if_really_stuck': None,
                'feedback': {
                    'content_id': 'feedback_1',
                    'html': '<p>Try again</p>'
                },
                'labelled_as_correct': False,
                'param_changes': [],
                'refresher_exploration_id': None,
                'missing_prerequisite_skill_id': None
            },
            'training_data': ['answer1', 'answer2', 'answer3'],
            'tagged_skill_misconception_id': None
        }]

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'state_name': feconf.DEFAULT_INIT_STATE_NAME,
            'property_name': (
                exp_domain.STATE_PROPERTY_INTERACTION_ANSWER_GROUPS),
            'new_value': interaction_answer_groups
        })]
        with self.assertRaisesRegex(
            Exception,
            'No classifier algorithm found for NumericInput interaction'
        ):
            with self.swap(feconf, 'ENABLE_ML_CLASSIFIERS', True):
                with self.swap(feconf, 'MIN_TOTAL_TRAINING_EXAMPLES', 2):
                    with self.swap(feconf, 'MIN_ASSIGNED_LABELS', 1):
                        exp_services.update_exploration(
                            self.owner_id, 'tes_exp_id', change_list, '')

    def test_reverting_an_exploration_maintains_classifier_models(self) -> None:
        """Test that when exploration is reverted to previous version
        it maintains appropriate classifier models mapping.
        """
        with self.swap(feconf, 'ENABLE_ML_CLASSIFIERS', True):
            self.save_new_valid_exploration(
                self.EXP_0_ID, self.owner_id, title='Bridges in England',
                category='Architecture', language_code='en')

        interaction_answer_groups: List[state_domain.AnswerGroupDict] = [{
            'rule_specs': [{
                'rule_type': 'Equals',
                'inputs': {
                    'x': {
                        'contentId': 'rule_input_3',
                        'normalizedStrSet': ['abc']
                    }
                },
            }],
            'outcome': {
                'dest': feconf.DEFAULT_INIT_STATE_NAME,
                'dest_if_really_stuck': None,
                'feedback': {
                    'content_id': 'feedback_1',
                    'html': '<p>Try again</p>'
                },
                'labelled_as_correct': False,
                'param_changes': [],
                'refresher_exploration_id': None,
                'missing_prerequisite_skill_id': None
            },
            'training_data': ['answer1', 'answer2', 'answer3'],
            'tagged_skill_misconception_id': None
        }]

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'state_name': feconf.DEFAULT_INIT_STATE_NAME,
            'property_name': (
                exp_domain.STATE_PROPERTY_INTERACTION_ANSWER_GROUPS),
            'new_value': interaction_answer_groups
        })]

        with self.swap(feconf, 'ENABLE_ML_CLASSIFIERS', True):
            with self.swap(feconf, 'MIN_TOTAL_TRAINING_EXAMPLES', 2):
                with self.swap(feconf, 'MIN_ASSIGNED_LABELS', 1):
                    exp_services.update_exploration(
                        self.owner_id, self.EXP_0_ID, change_list, '')

        exp = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        interaction_id = exp.states[
            feconf.DEFAULT_INIT_STATE_NAME].interaction.id
        # Ruling out the possibility of None for mypy type checking.
        assert interaction_id is not None
        algorithm_id = feconf.INTERACTION_CLASSIFIER_MAPPING[
            interaction_id]['algorithm_id']
        job = classifier_services.get_classifier_training_job(
            self.EXP_0_ID, exp.version, feconf.DEFAULT_INIT_STATE_NAME,
            algorithm_id)
        self.assertIsNotNone(job)

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'title',
            'new_value': 'A new title'
        })]

        with self.swap(feconf, 'ENABLE_ML_CLASSIFIERS', True):
            with self.swap(feconf, 'MIN_TOTAL_TRAINING_EXAMPLES', 2):
                with self.swap(feconf, 'MIN_ASSIGNED_LABELS', 1):
                    exp_services.update_exploration(
                        self.owner_id, self.EXP_0_ID, change_list, '')

                    exp = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
                    # Revert exploration to previous version.
                    exp_services.revert_exploration(
                        self.owner_id, self.EXP_0_ID, exp.version,
                        exp.version - 1)

        new_job = classifier_services.get_classifier_training_job(
            self.EXP_0_ID, exp.version, feconf.DEFAULT_INIT_STATE_NAME,
            algorithm_id)
        # Ruling out the possibility of None for mypy type checking.
        assert new_job is not None
        assert job is not None
        self.assertEqual(job.job_id, new_job.job_id)


class ExplorationQueriesUnitTests(ExplorationServicesUnitTests):
    """Tests query methods."""

    def test_raises_error_if_guest_user_try_to_publish_the_exploration(
        self
    ) -> None:
        guest_user = user_services.get_user_actions_info(None)
        with self.assertRaisesRegex(
            Exception,
            'To publish explorations and update users\' profiles, '
            'user must be logged in and have admin access.'
        ):
            exp_services.publish_exploration_and_update_user_profiles(
                guest_user, 'exp_id'
            )

    def test_get_exploration_titles_and_categories(self) -> None:
        self.assertEqual(
            exp_services.get_exploration_titles_and_categories([]), {})

        self.save_new_default_exploration('A', self.owner_id, title='TitleA')
        self.assertEqual(
            exp_services.get_exploration_titles_and_categories(['A']), {
                'A': {
                    'category': 'Algebra',
                    'title': 'TitleA'
                }
            })

        self.save_new_default_exploration('B', self.owner_id, title='TitleB')
        self.assertEqual(
            exp_services.get_exploration_titles_and_categories(['A']), {
                'A': {
                    'category': 'Algebra',
                    'title': 'TitleA'
                }
            })
        self.assertEqual(
            exp_services.get_exploration_titles_and_categories(['A', 'B']), {
                'A': {
                    'category': 'Algebra',
                    'title': 'TitleA',
                },
                'B': {
                    'category': 'Algebra',
                    'title': 'TitleB',
                },
            })
        self.assertEqual(
            exp_services.get_exploration_titles_and_categories(['A', 'C']), {
                'A': {
                    'category': 'Algebra',
                    'title': 'TitleA'
                }
            })

    def test_get_interaction_id_for_state(self) -> None:
        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)
        exp = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(exp.has_state_name('Introduction'), True)
        self.assertEqual(exp.has_state_name('Fake state name'), False)
        exp_services.update_exploration(
            self.owner_id,
            self.EXP_0_ID,
            _get_change_list(
                'Introduction', exp_domain.STATE_PROPERTY_INTERACTION_ID,
                'MultipleChoiceInput') +
            _get_change_list(
                'Introduction',
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                {
                    'choices': {
                        'value': [{
                            'content_id': 'ca_choices_0',
                            'html': '<p>Option A</p>'
                        }, {
                            'content_id': 'ca_choices_1',
                            'html': '<p>Option B</p>'
                        }]
                    },
                    'showChoicesInShuffledOrder': {'value': False}
                }
            ),
            ''
        )
        self.assertEqual(exp_services.get_interaction_id_for_state(
            self.EXP_0_ID, 'Introduction'), 'MultipleChoiceInput')
        with self.assertRaisesRegex(
            Exception, 'There exist no state in the exploration'):
            exp_services.get_interaction_id_for_state(
                self.EXP_0_ID, 'Fake state name')


class ExplorationSummaryQueriesUnitTests(ExplorationServicesUnitTests):
    """Tests exploration query methods which operate on ExplorationSummary
    objects.
    """

    EXP_ID_0: Final = '0_en_arch_bridges_in_england'
    EXP_ID_1: Final = '1_fi_arch_sillat_suomi'
    EXP_ID_2: Final = '2_en_welcome_introduce_oppia'
    EXP_ID_3: Final = '3_en_welcome_introduce_oppia_interactions'
    EXP_ID_4: Final = '4_en_welcome'
    EXP_ID_5: Final = '5_fi_welcome_vempain'
    EXP_ID_6: Final = '6_en_languages_learning_basic_verbs_in_spanish'
    EXP_ID_7: Final = '7_en_languages_private_exploration_in_spanish'

    def setUp(self) -> None:
        super().setUp()

        # Setup the explorations to fit into 2 different categoriers and 2
        # different language groups. Also, ensure 2 of them have similar
        # titles.
        self.save_new_valid_exploration(
            self.EXP_ID_0, self.owner_id, title='Bridges in England',
            category='Architecture', language_code='en')
        self.save_new_valid_exploration(
            self.EXP_ID_1, self.owner_id, title='Sillat Suomi',
            category='Architecture', language_code='fi')
        self.save_new_valid_exploration(
            self.EXP_ID_2, self.owner_id, title='Introduce Oppia',
            category='Welcome', language_code='en')
        self.save_new_valid_exploration(
            self.EXP_ID_3, self.owner_id,
            title='Introduce Interactions in Oppia',
            category='Welcome', language_code='en')
        self.save_new_valid_exploration(
            self.EXP_ID_4, self.owner_id, title='Welcome',
            category='Welcome', language_code='en')
        self.save_new_valid_exploration(
            self.EXP_ID_5, self.owner_id, title='Tervetuloa Oppia',
            category='Welcome', language_code='fi')
        self.save_new_valid_exploration(
            self.EXP_ID_6, self.owner_id,
            title='Learning basic verbs in Spanish',
            category='Languages', language_code='en')
        self.save_new_valid_exploration(
            self.EXP_ID_7, self.owner_id,
            title='Private exploration in Spanish',
            category='Languages', language_code='en')

        # Publish explorations 0-6. Private explorations should not show up in
        # a search query, even if they're indexed.
        rights_manager.publish_exploration(self.owner, self.EXP_ID_0)
        rights_manager.publish_exploration(self.owner, self.EXP_ID_1)
        rights_manager.publish_exploration(self.owner, self.EXP_ID_2)
        rights_manager.publish_exploration(self.owner, self.EXP_ID_3)
        rights_manager.publish_exploration(self.owner, self.EXP_ID_4)
        rights_manager.publish_exploration(self.owner, self.EXP_ID_5)
        rights_manager.publish_exploration(self.owner, self.EXP_ID_6)

        # Add the explorations to the search index.
        exp_services.index_explorations_given_ids([
            self.EXP_ID_0, self.EXP_ID_1, self.EXP_ID_2, self.EXP_ID_3,
            self.EXP_ID_4, self.EXP_ID_5, self.EXP_ID_6])

    def test_get_exploration_summaries_with_no_query(self) -> None:
        # An empty query should return all explorations.
        (exp_ids, search_offset) = (
            exp_services.get_exploration_ids_matching_query('', [], []))
        self.assertEqual(sorted(exp_ids), [
            self.EXP_ID_0, self.EXP_ID_1, self.EXP_ID_2, self.EXP_ID_3,
            self.EXP_ID_4, self.EXP_ID_5, self.EXP_ID_6
        ])
        self.assertIsNone(search_offset)

    def test_get_exploration_summaries_with_deleted_explorations(self) -> None:
        # Ensure a deleted exploration does not show up in search results.
        exp_services.delete_exploration(self.owner_id, self.EXP_ID_0)
        exp_services.delete_exploration(self.owner_id, self.EXP_ID_1)
        exp_services.delete_exploration(self.owner_id, self.EXP_ID_3)
        exp_services.delete_exploration(self.owner_id, self.EXP_ID_5)
        exp_services.delete_exploration(self.owner_id, self.EXP_ID_6)

        exp_ids = (
            exp_services.get_exploration_ids_matching_query('', [], []))[0]
        self.assertEqual(sorted(exp_ids), [self.EXP_ID_2, self.EXP_ID_4])

        exp_services.delete_exploration(self.owner_id, self.EXP_ID_2)
        exp_services.delete_exploration(self.owner_id, self.EXP_ID_4)

        # If no explorations are loaded, a blank query should not get any
        # explorations.
        self.assertEqual(
            exp_services.get_exploration_ids_matching_query('', [], []),
            ([], None))

    def test_get_exploration_summaries_with_deleted_explorations_multi(
        self
    ) -> None:
        # Ensure a deleted exploration does not show up in search results.
        exp_services.delete_explorations(
            self.owner_id,
            [self.EXP_ID_0, self.EXP_ID_1, self.EXP_ID_3,
             self.EXP_ID_5, self.EXP_ID_6])

        exp_ids = (
            exp_services.get_exploration_ids_matching_query('', [], []))[0]
        self.assertEqual(sorted(exp_ids), [self.EXP_ID_2, self.EXP_ID_4])

        exp_services.delete_explorations(
            self.owner_id, [self.EXP_ID_2, self.EXP_ID_4])

        # If no explorations are loaded, a blank query should not get any
        # explorations.
        self.assertEqual(
            exp_services.get_exploration_ids_matching_query('', [], []),
            ([], None))

    def test_get_subscribed_users_activity_ids_with_deleted_explorations(
        self
    ) -> None:
        # Ensure a deleted exploration does not show up in subscribed users
        # activity ids.
        subscription_services.subscribe_to_exploration(
            self.owner_id, self.EXP_ID_0)
        self.assertIn(
            self.EXP_ID_0,
            subscription_services.get_exploration_ids_subscribed_to(
                self.owner_id))
        exp_services.delete_exploration(self.owner_id, self.EXP_ID_0)
        self.process_and_flush_pending_tasks()
        self.assertNotIn(
            self.EXP_ID_0,
            subscription_services.get_exploration_ids_subscribed_to(
                self.owner_id))

    def test_search_exploration_summaries(self) -> None:
        # Search within the 'Architecture' category.
        exp_ids, _ = exp_services.get_exploration_ids_matching_query(
            '', ['Architecture'], [])
        self.assertEqual(sorted(exp_ids), [self.EXP_ID_0, self.EXP_ID_1])

        # Search for explorations in Finnish.
        exp_ids, _ = exp_services.get_exploration_ids_matching_query(
            '', [], ['fi'])
        self.assertEqual(sorted(exp_ids), [self.EXP_ID_1, self.EXP_ID_5])

        # Search for Finnish explorations in the 'Architecture' category.
        exp_ids, _ = exp_services.get_exploration_ids_matching_query(
            '', ['Architecture'], ['fi'])
        self.assertEqual(sorted(exp_ids), [self.EXP_ID_1])

        # Search for explorations containing 'Oppia'.
        exp_ids, _ = exp_services.get_exploration_ids_matching_query(
            'Oppia', [], [])
        self.assertEqual(
            sorted(exp_ids), [self.EXP_ID_2, self.EXP_ID_3, self.EXP_ID_5])

        # Search for explorations containing 'Oppia' and 'Introduce'.
        exp_ids, _ = exp_services.get_exploration_ids_matching_query(
            'Oppia Introduce', [], [])
        self.assertEqual(sorted(exp_ids), [self.EXP_ID_2, self.EXP_ID_3])

        # Search for explorations containing 'England' in English.
        exp_ids, _ = exp_services.get_exploration_ids_matching_query(
            'England', [], ['en'])
        self.assertEqual(sorted(exp_ids), [self.EXP_ID_0])

        # Search for explorations containing 'in'.
        exp_ids, _ = exp_services.get_exploration_ids_matching_query(
            'in', [], [])
        self.assertEqual(
            sorted(exp_ids), [self.EXP_ID_0, self.EXP_ID_3, self.EXP_ID_6])

        # Search for explorations containing 'in' in the 'Architecture' and
        # 'Welcome' categories.
        exp_ids, _ = exp_services.get_exploration_ids_matching_query(
            'in', ['Architecture', 'Welcome'], [])
        self.assertEqual(sorted(exp_ids), [self.EXP_ID_0, self.EXP_ID_3])

    def test_exploration_summaries_pagination_in_filled_search_results(
        self
    ) -> None:
        # Ensure the maximum number of explorations that can fit on the search
        # results page is maintained by the summaries function.
        with self.swap(feconf, 'SEARCH_RESULTS_PAGE_SIZE', 3):
            # Need to load 3 pages to find all of the explorations. Since the
            # returned order is arbitrary, we need to concatenate the results
            # to ensure all explorations are returned. We validate the correct
            # length is returned each time.
            found_exp_ids = []

            # Page 1: 3 initial explorations.
            (exp_ids, search_offset) = (
                exp_services.get_exploration_ids_matching_query(
                    '', [], []))
            self.assertEqual(len(exp_ids), 3)
            self.assertIsNotNone(search_offset)
            found_exp_ids += exp_ids

            # Page 2: 3 more explorations.
            (exp_ids, search_offset) = (
                exp_services.get_exploration_ids_matching_query(
                    '', [], [], offset=search_offset))
            self.assertEqual(len(exp_ids), 3)
            self.assertIsNotNone(search_offset)
            found_exp_ids += exp_ids

            # Page 3: 1 final exploration.
            (exp_ids, search_offset) = (
                exp_services.get_exploration_ids_matching_query(
                    '', [], [], offset=search_offset))
            self.assertEqual(len(exp_ids), 1)
            self.assertIsNone(search_offset)
            found_exp_ids += exp_ids

            # Validate all explorations were seen.
            self.assertEqual(sorted(found_exp_ids), [
                self.EXP_ID_0, self.EXP_ID_1, self.EXP_ID_2, self.EXP_ID_3,
                self.EXP_ID_4, self.EXP_ID_5, self.EXP_ID_6])

    def test_get_exploration_ids_matching_query_with_stale_exploration_ids(
        self
    ) -> None:
        observed_log_messages = []

        def _mock_logging_function(msg: str, *args: str) -> None:
            """Mocks logging.error()."""
            observed_log_messages.append(msg % args)

        logging_swap = self.swap(logging, 'error', _mock_logging_function)
        search_results_page_size_swap = self.swap(
            feconf, 'SEARCH_RESULTS_PAGE_SIZE', 6)
        max_iterations_swap = self.swap(exp_services, 'MAX_ITERATIONS', 1)

        def _mock_delete_documents_from_index(
            unused_doc_ids: List[str], unused_index: str
        ) -> None:
            """Mocks delete_documents_from_index() so that the exploration is
            not deleted from the document on deleting the exploration. This is
            required to fetch stale exploration ids.
            """
            pass

        with self.swap(
            search_services, 'delete_documents_from_index',
            _mock_delete_documents_from_index):
            exp_services.delete_exploration(self.owner_id, self.EXP_ID_0)
            exp_services.delete_exploration(self.owner_id, self.EXP_ID_1)

        with logging_swap, search_results_page_size_swap, max_iterations_swap:
            (exp_ids, _) = (
                exp_services.get_exploration_ids_matching_query('', [], []))

        self.assertEqual(
            observed_log_messages,
            [
                'Search index contains stale exploration ids: '
                '0_en_arch_bridges_in_england, 1_fi_arch_sillat_suomi',
                'Could not fulfill search request for query string ; at '
                'least 1 retries were needed.'
            ]
        )
        self.assertEqual(len(exp_ids), 4)


class ExplorationCreateAndDeleteUnitTests(ExplorationServicesUnitTests):
    """Test creation and deletion methods."""

    def test_soft_deletion_of_exploration(self) -> None:
        """Test that soft deletion of exploration works correctly."""
        # TODO(sll): Add tests for deletion of states and version snapshots.

        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)
        # The exploration shows up in queries.
        self.assertEqual(
            count_at_least_editable_exploration_summaries(self.owner_id), 1)

        exp_services.delete_exploration(self.owner_id, self.EXP_0_ID)
        with self.assertRaisesRegex(
            Exception,
            'Entity for class ExplorationModel with id An_exploration_0_id '
            'not found'):
            exp_fetchers.get_exploration_by_id(self.EXP_0_ID)

        # The deleted exploration does not show up in any queries.
        self.assertEqual(
            count_at_least_editable_exploration_summaries(self.owner_id), 0)

        # But the model still exists in the backend.
        self.assertIsNotNone(
            exp_models.ExplorationModel.get_by_id(self.EXP_0_ID))

        # The exploration summary is deleted, however.
        self.assertIsNone(exp_models.ExpSummaryModel.get_by_id(self.EXP_0_ID))

        # The delete commit exists.
        self.assertIsNotNone(
            exp_models.ExplorationCommitLogEntryModel.get_by_id(
                'exploration-%s-%s' % (self.EXP_0_ID, 1)))

        # The snapshot models exist.
        exp_snapshot_id = (
            exp_models.ExplorationModel.get_snapshot_id(self.EXP_0_ID, 1))
        self.assertIsNotNone(
            exp_models.ExplorationSnapshotMetadataModel.get_by_id(
                exp_snapshot_id))
        self.assertIsNotNone(
            exp_models.ExplorationSnapshotContentModel.get_by_id(
                exp_snapshot_id))
        exp_rights_snapshot_id = (
            exp_models.ExplorationRightsModel.get_snapshot_id(self.EXP_0_ID, 1))
        self.assertIsNotNone(
            exp_models.ExplorationRightsSnapshotMetadataModel.get_by_id(
                exp_rights_snapshot_id))
        self.assertIsNotNone(
            exp_models.ExplorationRightsSnapshotContentModel.get_by_id(
                exp_rights_snapshot_id))

    def test_deletion_of_multiple_explorations_empty(self) -> None:
        """Test that delete_explorations with empty list works correctly."""
        exp_services.delete_explorations(self.owner_id, [])
        self.process_and_flush_pending_tasks()

    def test_soft_deletion_of_multiple_explorations(self) -> None:
        """Test that soft deletion of explorations works correctly."""
        # TODO(sll): Add tests for deletion of states and version snapshots.

        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)
        self.save_new_default_exploration(self.EXP_1_ID, self.owner_id)
        # The explorations show up in queries.
        self.assertEqual(
            count_at_least_editable_exploration_summaries(self.owner_id), 2)

        exp_services.delete_explorations(
            self.owner_id, [self.EXP_0_ID, self.EXP_1_ID])
        with self.assertRaisesRegex(
            Exception,
            'Entity for class ExplorationModel with id An_exploration_0_id '
            'not found'):
            exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        with self.assertRaisesRegex(
            Exception,
            'Entity for class ExplorationModel with id An_exploration_1_id '
            'not found'):
            exp_fetchers.get_exploration_by_id(self.EXP_1_ID)

        # The deleted exploration does not show up in any queries.
        self.assertEqual(
            count_at_least_editable_exploration_summaries(self.owner_id), 0)

        # But the models still exist in the backend.
        self.assertIsNotNone(
            exp_models.ExplorationModel.get_by_id(self.EXP_0_ID))
        self.assertIsNotNone(
            exp_models.ExplorationModel.get_by_id(self.EXP_1_ID))

        # The exploration summaries are deleted, however.
        self.assertIsNone(exp_models.ExpSummaryModel.get_by_id(self.EXP_0_ID))
        self.assertIsNone(exp_models.ExpSummaryModel.get_by_id(self.EXP_1_ID))

        # The delete commits exist.
        self.assertIsNotNone(
            exp_models.ExplorationCommitLogEntryModel.get_by_id(
                'exploration-%s-%s' % (self.EXP_0_ID, 1)))
        self.assertIsNotNone(
            exp_models.ExplorationCommitLogEntryModel.get_by_id(
                'exploration-%s-%s' % (self.EXP_1_ID, 1)))

        # The snapshot models exist.
        exp_0_snapshot_id = (
            exp_models.ExplorationModel.get_snapshot_id(self.EXP_0_ID, 1))
        exp_1_snapshot_id = (
            exp_models.ExplorationModel.get_snapshot_id(self.EXP_1_ID, 1))
        self.assertIsNotNone(
            exp_models.ExplorationSnapshotMetadataModel.get_by_id(
                exp_0_snapshot_id))
        self.assertIsNotNone(
            exp_models.ExplorationSnapshotContentModel.get_by_id(
                exp_0_snapshot_id))
        self.assertIsNotNone(
            exp_models.ExplorationSnapshotMetadataModel.get_by_id(
                exp_1_snapshot_id))
        self.assertIsNotNone(
            exp_models.ExplorationSnapshotContentModel.get_by_id(
                exp_1_snapshot_id))
        exp_0_rights_snapshot_id = (
            exp_models.ExplorationRightsModel.get_snapshot_id(self.EXP_0_ID, 1))
        exp_1_rights_snapshot_id = (
            exp_models.ExplorationRightsModel.get_snapshot_id(self.EXP_1_ID, 1))
        self.assertIsNotNone(
            exp_models.ExplorationRightsSnapshotMetadataModel.get_by_id(
                exp_0_rights_snapshot_id))
        self.assertIsNotNone(
            exp_models.ExplorationRightsSnapshotContentModel.get_by_id(
                exp_0_rights_snapshot_id))
        self.assertIsNotNone(
            exp_models.ExplorationRightsSnapshotMetadataModel.get_by_id(
                exp_1_rights_snapshot_id))
        self.assertIsNotNone(
            exp_models.ExplorationRightsSnapshotContentModel.get_by_id(
                exp_1_rights_snapshot_id))

    def test_hard_deletion_of_exploration(self) -> None:
        """Test that hard deletion of exploration works correctly."""
        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)
        # The exploration shows up in queries.
        self.assertEqual(
            count_at_least_editable_exploration_summaries(self.owner_id), 1)

        exp_services.delete_exploration(
            self.owner_id, self.EXP_0_ID, force_deletion=True)
        with self.assertRaisesRegex(
            Exception,
            'Entity for class ExplorationModel with id An_exploration_0_id '
            'not found'):
            exp_fetchers.get_exploration_by_id(self.EXP_0_ID)

        # The deleted exploration does not show up in any queries.
        self.assertEqual(
            count_at_least_editable_exploration_summaries(self.owner_id), 0)

        # The exploration model has been purged from the backend.
        self.assertIsNone(
            exp_models.ExplorationModel.get_by_id(self.EXP_0_ID))

    def test_hard_deletion_of_multiple_explorations(self) -> None:
        """Test that hard deletion of explorations works correctly."""
        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)
        self.save_new_default_exploration(self.EXP_1_ID, self.owner_id)
        # The explorations show up in queries.
        self.assertEqual(
            count_at_least_editable_exploration_summaries(self.owner_id), 2)

        exp_services.delete_explorations(
            self.owner_id, [self.EXP_0_ID, self.EXP_1_ID], force_deletion=True)
        with self.assertRaisesRegex(
            Exception,
            'Entity for class ExplorationModel with id An_exploration_0_id '
            'not found'):
            exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        with self.assertRaisesRegex(
            Exception,
            'Entity for class ExplorationModel with id An_exploration_1_id '
            'not found'):
            exp_fetchers.get_exploration_by_id(self.EXP_1_ID)

        # The deleted explorations does not show up in any queries.
        self.assertEqual(
            count_at_least_editable_exploration_summaries(self.owner_id), 0)

        # The exploration models have been purged from the backend.
        self.assertIsNone(
            exp_models.ExplorationModel.get_by_id(self.EXP_0_ID))
        self.assertIsNone(
            exp_models.ExplorationModel.get_by_id(self.EXP_1_ID))

        # The exploration summary models have been purged from the backend.
        self.assertIsNone(
            exp_models.ExpSummaryModel.get_by_id(self.EXP_0_ID))
        self.assertIsNone(
            exp_models.ExpSummaryModel.get_by_id(self.EXP_1_ID))

    def test_summaries_of_hard_deleted_explorations(self) -> None:
        """Test that summaries of hard deleted explorations are
        correctly deleted.
        """
        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)

        exp_services.delete_exploration(
            self.owner_id, self.EXP_0_ID, force_deletion=True)
        with self.assertRaisesRegex(
            Exception,
            'Entity for class ExplorationModel with id An_exploration_0_id '
            'not found'):
            exp_fetchers.get_exploration_by_id(self.EXP_0_ID)

        # The deleted exploration summary does not show up in any queries.
        self.assertEqual(
            count_at_least_editable_exploration_summaries(self.owner_id), 0)

        # The exploration summary model has been purged from the backend.
        self.assertIsNone(
            exp_models.ExpSummaryModel.get_by_id(self.EXP_0_ID))

    def test_recommendations_of_deleted_explorations_are_deleted(self) -> None:
        """Test that recommendations for deleted explorations are correctly
        deleted.
        """
        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)
        recommendations_models.ExplorationRecommendationsModel(
            id=self.EXP_0_ID,
            recommended_exploration_ids=[]
        ).put()
        self.save_new_default_exploration(self.EXP_1_ID, self.owner_id)
        recommendations_models.ExplorationRecommendationsModel(
            id=self.EXP_1_ID,
            recommended_exploration_ids=[]
        ).put()

        exp_services.delete_explorations(
            self.owner_id, [self.EXP_0_ID, self.EXP_1_ID])

        # The recommendations model has been purged from the backend.
        self.assertIsNone(
            recommendations_models.ExplorationRecommendationsModel.get_by_id(
                self.EXP_0_ID))
        self.assertIsNone(
            recommendations_models.ExplorationRecommendationsModel.get_by_id(
                self.EXP_1_ID))

    def test_opportunity_of_deleted_explorations_are_deleted(self) -> None:
        """Test that opportunity summary for deleted explorations are correctly
        deleted.
        """
        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)
        opportunity_models.ExplorationOpportunitySummaryModel(
            id=self.EXP_0_ID,
            topic_id='topic_id',
            topic_name='topic_name',
            story_id='story_id',
            story_title='story_title',
            chapter_title='chapter_title',
            content_count=1,
        ).put()
        self.save_new_default_exploration(self.EXP_1_ID, self.owner_id)
        opportunity_models.ExplorationOpportunitySummaryModel(
            id=self.EXP_1_ID,
            topic_id='topic_id',
            topic_name='topic_name',
            story_id='story_id',
            story_title='story_title',
            chapter_title='chapter_title',
            content_count=1,
        ).put()

        exp_services.delete_explorations(
            self.owner_id, [self.EXP_0_ID, self.EXP_1_ID])

        # The opportunity model has been purged from the backend.
        self.assertIsNone(
            opportunity_models.ExplorationOpportunitySummaryModel.get_by_id(
                self.EXP_0_ID))
        self.assertIsNone(
            opportunity_models.ExplorationOpportunitySummaryModel.get_by_id(
                self.EXP_1_ID))

    def test_activities_of_deleted_explorations_are_deleted(self) -> None:
        """Test that opportunity summary for deleted explorations are correctly
        deleted.
        """
        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)
        user_models.CompletedActivitiesModel(
            id=self.editor_id,
            exploration_ids=[self.EXP_0_ID],
        ).put()
        self.save_new_default_exploration(self.EXP_1_ID, self.owner_id)
        user_models.IncompleteActivitiesModel(
            id=self.owner_id,
            exploration_ids=[self.EXP_1_ID],
        ).put()

        exp_services.delete_explorations(
            self.owner_id, [self.EXP_0_ID, self.EXP_1_ID])
        self.process_and_flush_pending_tasks()

        self.assertEqual(
            user_models.CompletedActivitiesModel.get(
                self.editor_id, strict=True
            ).exploration_ids,
            []
        )
        self.assertEqual(
            user_models.IncompleteActivitiesModel.get(
                self.owner_id, strict=True
            ).exploration_ids,
            []
        )

    def test_user_data_of_deleted_explorations_are_deleted(self) -> None:
        """Test that user data for deleted explorations are deleted."""
        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)
        user_models.ExplorationUserDataModel(
            id='%s.%s' % (self.owner_id, self.EXP_0_ID),
            user_id=self.owner_id,
            exploration_id=self.EXP_0_ID,
        ).put()
        user_models.ExplorationUserDataModel(
            id='%s.%s' % ('other_user_id', self.EXP_0_ID),
            user_id='other_user_id',
            exploration_id=self.EXP_0_ID,
        ).put()
        self.save_new_default_exploration(self.EXP_1_ID, self.owner_id)
        user_models.ExplorationUserDataModel(
            id='%s.%s' % (self.owner_id, self.EXP_1_ID),
            user_id=self.owner_id,
            exploration_id=self.EXP_1_ID,
        ).put()

        exp_services.delete_explorations(
            self.owner_id, [self.EXP_0_ID, self.EXP_1_ID])
        self.process_and_flush_pending_tasks()

        # The user data model has been purged from the backend.
        self.assertIsNone(
            user_models.ExplorationUserDataModel.get(
                self.owner_id, self.EXP_0_ID))
        self.assertIsNone(
            user_models.ExplorationUserDataModel.get(
                'other_user_id', self.EXP_0_ID))
        self.assertIsNone(
            user_models.ExplorationUserDataModel.get(
                self.owner_id, self.EXP_1_ID))

    def test_deleted_explorations_are_removed_from_user_contributions(
        self
    ) -> None:
        """Test that user data for deleted explorations are deleted."""
        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)
        user_models.UserContributionsModel(
            id=self.owner_id,
            created_exploration_ids=[self.EXP_0_ID, self.EXP_2_ID],
        ).put()
        user_models.UserContributionsModel(
            id='user_id',
            edited_exploration_ids=[self.EXP_0_ID, self.EXP_2_ID],
        ).put()
        self.save_new_default_exploration(self.EXP_1_ID, self.owner_id)
        user_models.UserContributionsModel(
            id='other_user_id',
            created_exploration_ids=[self.EXP_0_ID, self.EXP_2_ID],
            edited_exploration_ids=[self.EXP_1_ID],
        ).put()

        exp_services.delete_explorations(
            self.owner_id, [self.EXP_0_ID, self.EXP_1_ID])
        self.process_and_flush_pending_tasks()

        self.assertEqual(
            user_models.UserContributionsModel.get(
                self.owner_id
            ).created_exploration_ids,
            [self.EXP_2_ID]
        )
        self.assertEqual(
            user_models.UserContributionsModel.get(
                'user_id'
            ).edited_exploration_ids,
            [self.EXP_2_ID]
        )
        self.assertEqual(
            user_models.UserContributionsModel.get(
                'other_user_id'
            ).created_exploration_ids,
            [self.EXP_2_ID]
        )
        self.assertEqual(
            user_models.UserContributionsModel.get(
                'other_user_id'
            ).edited_exploration_ids,
            []
        )

    def test_feedbacks_belonging_to_exploration_are_deleted(self) -> None:
        """Tests that feedbacks belonging to exploration are deleted."""
        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)
        thread_1_id = feedback_services.create_thread(
            feconf.ENTITY_TYPE_EXPLORATION,
            self.EXP_0_ID,
            self.owner_id,
            'subject',
            'text'
        )
        thread_2_id = feedback_services.create_thread(
            feconf.ENTITY_TYPE_EXPLORATION,
            self.EXP_0_ID,
            self.owner_id,
            'subject 2',
            'text 2'
        )

        exp_services.delete_explorations(self.owner_id, [self.EXP_0_ID])

        self.assertIsNone(feedback_models.GeneralFeedbackThreadModel.get_by_id(
            thread_1_id))
        self.assertIsNone(feedback_models.GeneralFeedbackThreadModel.get_by_id(
            thread_2_id))

    def test_exploration_is_removed_from_index_when_deleted(self) -> None:
        """Tests that exploration is removed from the search index when
        deleted.
        """
        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)

        def mock_delete_docs(doc_ids: List[Dict[str, str]], index: str) -> None:
            self.assertEqual(index, exp_services.SEARCH_INDEX_EXPLORATIONS)
            self.assertEqual(doc_ids, [self.EXP_0_ID])

        delete_docs_swap = self.swap(
            search_services, 'delete_documents_from_index', mock_delete_docs)

        with delete_docs_swap:
            exp_services.delete_exploration(self.owner_id, self.EXP_0_ID)

    def test_explorations_are_removed_from_index_when_deleted(self) -> None:
        """Tests that explorations are removed from the search index when
        deleted.
        """
        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)
        self.save_new_default_exploration(self.EXP_1_ID, self.owner_id)

        def mock_delete_docs(doc_ids: List[Dict[str, str]], index: str) -> None:
            self.assertEqual(index, exp_services.SEARCH_INDEX_EXPLORATIONS)
            self.assertEqual(doc_ids, [self.EXP_0_ID, self.EXP_1_ID])

        delete_docs_swap = self.swap(
            search_services, 'delete_documents_from_index', mock_delete_docs)

        with delete_docs_swap:
            exp_services.delete_explorations(
                self.owner_id, [self.EXP_0_ID, self.EXP_1_ID])

    def test_no_errors_are_raised_when_creating_default_exploration(
        self
    ) -> None:
        exploration = exp_domain.Exploration.create_default_exploration(
            self.EXP_0_ID)
        exp_services.save_new_exploration(self.owner_id, exploration)

    def test_that_default_exploration_fails_strict_validation(self) -> None:
        exploration = exp_domain.Exploration.create_default_exploration(
            self.EXP_0_ID)
        with self.assertRaisesRegex(
            utils.ValidationError,
            'This state does not have any interaction specified.'
            ):
            exploration.validate(strict=True)

    def test_save_new_exploration_with_ml_classifiers(self) -> None:
        exploration_id = 'eid'
        test_exp_filepath = os.path.join(
            feconf.TESTS_DATA_DIR, 'string_classifier_test.yaml')
        yaml_content = utils.get_file_contents(test_exp_filepath)
        assets_list: List[Tuple[str, bytes]] = []
        with self.swap(feconf, 'ENABLE_ML_CLASSIFIERS', True):
            exp_services.save_new_exploration_from_yaml_and_assets(
                feconf.SYSTEM_COMMITTER_ID, yaml_content, exploration_id,
                assets_list)

        exploration = exp_fetchers.get_exploration_by_id(exploration_id)
        state_with_training_data = exploration.states['Home']
        self.assertIsNotNone(
            state_with_training_data)
        self.assertEqual(len(state_with_training_data.to_dict()), 8)

    def test_save_and_retrieve_exploration(self) -> None:
        self.save_new_valid_exploration(self.EXP_0_ID, self.owner_id)
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'param_specs',
                'new_value': {
                    'theParameter':
                        param_domain.ParamSpec('UnicodeString').to_dict()
                }
            })],
            '')

        retrieved_exploration = exp_fetchers.get_exploration_by_id(
            self.EXP_0_ID)
        self.assertEqual(retrieved_exploration.title, 'A title')
        self.assertEqual(retrieved_exploration.category, 'Algebra')
        self.assertEqual(len(retrieved_exploration.states), 1)
        self.assertEqual(len(retrieved_exploration.param_specs), 1)
        self.assertEqual(
            list(retrieved_exploration.param_specs.keys())[0], 'theParameter')

    def test_save_and_retrieve_exploration_summary(self) -> None:
        self.save_new_valid_exploration(self.EXP_0_ID, self.owner_id)

        # Change param spec.
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'param_specs',
                'new_value': {
                    'theParameter':
                        param_domain.ParamSpec('UnicodeString').to_dict()
                }
            })], '')

        # Change title and category.
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'A new title'
            }), exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'category',
                'new_value': 'A new category'
            })], 'Change title and category')

        self.process_and_flush_pending_tasks()
        retrieved_exp_summary = exp_fetchers.get_exploration_summary_by_id(
            self.EXP_0_ID)

        self.assertEqual(retrieved_exp_summary.title, 'A new title')
        self.assertEqual(retrieved_exp_summary.category, 'A new category')
        self.assertEqual(retrieved_exp_summary.contributor_ids, [self.owner_id])

    def test_apply_change_list(self) -> None:
        self.save_new_linear_exp_with_state_names_and_interactions(
            self.EXP_0_ID, self.owner_id, ['State 1', 'State 2'],
            ['TextInput'], category='Algebra')

        recorded_voiceovers_dict = {
            'voiceovers_mapping': {
                'content': {
                    'en': {
                        'filename': 'filename3.mp3',
                        'file_size_bytes': 3000,
                        'needs_update': False,
                        'duration_secs': 42.43
                    }
                },
                'default_outcome': {},
                'ca_placeholder_0': {}
            }
        }
        change_list_voiceover = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name': (
                exp_domain.STATE_PROPERTY_RECORDED_VOICEOVERS),
            'state_name': 'State 1',
            'new_value': recorded_voiceovers_dict
        })]
        changed_exploration_voiceover = (
            exp_services.apply_change_list(
                self.EXP_0_ID, change_list_voiceover))
        changed_exp_voiceover_obj = (
            changed_exploration_voiceover.states['State 1'].recorded_voiceovers
        )
        self.assertDictEqual(
            changed_exp_voiceover_obj.to_dict(),
            recorded_voiceovers_dict)
        change_list_objective = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'objective',
            'new_value': 'new objective'
        })]
        changed_exploration_objective = (
            exp_services.apply_change_list(
                self.EXP_0_ID,
                change_list_objective))
        self.assertEqual(
            changed_exploration_objective.objective,
            'new objective')

    def test_publish_exploration_and_update_user_profiles(self) -> None:
        self.save_new_valid_exploration(self.EXP_0_ID, self.owner_id)
        exp_services.update_exploration(
            self.editor_id, self.EXP_0_ID,
            [
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                    'property_name': 'title',
                    'new_value': 'A new title'
                })
            ],
            'changed title'
        )
        exp_services.update_exploration(
            self.voice_artist_id, self.EXP_0_ID,
            [
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                    'property_name': 'title',
                    'new_value': 'Another new title'
                })
            ],
            'changed title again'
        )
        owner_action = user_services.get_user_actions_info(self.owner_id)
        exp_services.publish_exploration_and_update_user_profiles(
            owner_action, self.EXP_0_ID)
        updated_summary = (
            exp_fetchers.get_exploration_summary_by_id(self.EXP_0_ID))
        contributer_ids = updated_summary.contributor_ids
        self.assertEqual(len(contributer_ids), 3)
        self.assertFalse(updated_summary.is_private())
        self.assertIn(self.owner_id, contributer_ids)
        self.assertIn(self.editor_id, contributer_ids)
        self.assertIn(self.voice_artist_id, contributer_ids)

    def test_is_voiceover_change_list(self) -> None:
        recorded_voiceovers_dict = {
            'voiceovers_mapping': {
                'content': {
                    'en': {
                        'filename': 'filename3.mp3',
                        'file_size_bytes': 3000,
                        'needs_update': False,
                        'duration_secs': 42.43
                    }
                },
                'default_outcome': {},
                'ca_placeholder_0': {}
            }
        }
        change_list_voiceover = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name': (
                exp_domain.STATE_PROPERTY_RECORDED_VOICEOVERS),
            'state_name': 'State 1',
            'new_value': recorded_voiceovers_dict
        })]
        self.assertTrue(
            exp_services.is_voiceover_change_list(change_list_voiceover))
        not_voiceover_change_list = [exp_domain.ExplorationChange({
            'cmd': 'edit_exploration_property',
            'property_name': 'title',
            'new_value': 'New title'
        })]
        self.assertFalse(
            exp_services.is_voiceover_change_list(not_voiceover_change_list))

    def test_validation_for_valid_exploration(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id,
            category='Algebra'
        )
        errors = exp_services.validate_exploration_for_story(exploration, False)
        self.assertEqual(len(errors), 0)

    def test_validation_fail_for_exploration_for_invalid_language(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, end_state_name='end',
            language_code='bn', category='Algebra')
        error_string = (
            'Invalid language %s found for exploration '
            'with ID %s. This language is not supported for explorations '
            'in a story on the mobile app.' %
            (exploration.language_code, exploration.id))
        errors = exp_services.validate_exploration_for_story(exploration, False)
        self.assertEqual(len(errors), 1)
        self.assertEqual(errors[0], error_string)
        with self.assertRaisesRegex(utils.ValidationError, error_string):
            exp_services.validate_exploration_for_story(exploration, True)

    def test_validate_exploration_for_default_category(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, category='Test')
        error_string = (
            'Expected all explorations in a story to '
            'be of a default category. '
            'Invalid exploration: %s' % exploration.id)
        errors = exp_services.validate_exploration_for_story(exploration, False)
        self.assertEqual(len(errors), 1)
        self.assertEqual(errors[0], error_string)
        with self.assertRaisesRegex(utils.ValidationError, error_string):
            exp_services.validate_exploration_for_story(exploration, True)

    def test_validate_exploration_for_param_specs(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, category='Algebra')
        exploration.param_specs = {
            'myParam': param_domain.ParamSpec('UnicodeString')}
        error_string = (
            'Expected no exploration in a story to have parameter '
            'values in it. Invalid exploration: %s' % exploration.id)
        errors = exp_services.validate_exploration_for_story(exploration, False)
        self.assertEqual(len(errors), 1)
        self.assertEqual(errors[0], error_string)
        with self.assertRaisesRegex(utils.ValidationError, error_string):
            exp_services.validate_exploration_for_story(exploration, True)

    def test_validate_exploration_for_invalid_interaction_id(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, category='Algebra')
        error_string = (
            'Invalid interaction %s in exploration '
            'with ID: %s. This interaction is not supported for '
            'explorations in a story on the '
            'mobile app.' % ('CodeRepl', exploration.id))
        change_list = [
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'state_name': exploration.init_state_name,
                'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
                'new_value': 'CodeRepl'
            }),
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'state_name': exploration.init_state_name,
                'property_name': (
                    exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS),
                'new_value': {
                    'language': {
                        'value': 'python'
                    },
                    'placeholder': {
                        'value': '# Type your code here.'
                    },
                    'preCode': {
                        'value': ''
                    },
                    'postCode': {
                        'value': ''
                    }
                }
            })
        ]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list, 'Changed to CodeRepl')
        updated_exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        errors = exp_services.validate_exploration_for_story(
            updated_exploration, False)
        self.assertEqual(len(errors), 1)
        self.assertEqual(errors[0], error_string)
        with self.assertRaisesRegex(
            utils.ValidationError, error_string):
            exp_services.validate_exploration_for_story(
                updated_exploration, True)

    def test_validation_fail_for_end_exploration(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, category='Algebra')
        error_string = (
            'Explorations in a story are not expected to contain '
            'exploration recommendations. Exploration with ID: '
            '%s contains exploration recommendations in its '
            'EndExploration interaction.' % (exploration.id))
        change_list = [
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'state_name': exploration.init_state_name,
                'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
                'new_value': 'EndExploration'
            }),
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'state_name': exploration.init_state_name,
                'property_name': (
                    exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS),
                'new_value': {
                    'recommendedExplorationIds': {
                        'value': [
                            'EXP_1',
                            'EXP_2'
                        ]
                    }
                }
            }),
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'property_name': (
                    exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME),
                'state_name': exploration.init_state_name,
                'new_value': None})
        ]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID,
            change_list, 'Changed to EndExploration')
        updated_exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        errors = exp_services.validate_exploration_for_story(
            updated_exploration, False)
        self.assertEqual(len(errors), 1)
        self.assertEqual(errors[0], error_string)
        with self.assertRaisesRegex(
            utils.ValidationError, error_string):
            exp_services.validate_exploration_for_story(
                updated_exploration, True)

    def test_validation_fail_for_multiple_choice_exploration(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, category='Algebra')
        error_string = (
            'Exploration in a story having MultipleChoiceInput '
            'interaction should have at least 4 choices present. '
            'Exploration with ID %s and state name %s have fewer than '
            '4 choices.' % (exploration.id, exploration.init_state_name))
        change_list = [
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'state_name': exploration.init_state_name,
                'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
                'new_value': 'MultipleChoiceInput'
            }),
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'state_name': exploration.init_state_name,
                'property_name': (
                    exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS),
                'new_value': {
                    'choices': {
                        'value': [
                            {
                                'content_id': 'ca_choices_0',
                                'html': '<p>1</p>'
                            },
                            {
                                'content_id': 'ca_choices_1',
                                'html': '<p>2</p>'
                            }
                        ]
                    },
                    'showChoicesInShuffledOrder': {
                        'value': True
                    }
                }
            })
        ]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID,
            change_list, 'Changed to MultipleChoiceInput')
        updated_exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        errors = exp_services.validate_exploration_for_story(
            updated_exploration, False)
        self.assertEqual(len(errors), 1)
        self.assertEqual(errors[0], error_string)
        with self.assertRaisesRegex(
            utils.ValidationError, error_string):
            exp_services.validate_exploration_for_story(
                updated_exploration, True)

    def test_validation_fail_for_android_rte_content(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, category='Algebra')
        error_string = (
            'RTE content in state %s of exploration '
            'with ID %s is not supported on mobile for explorations '
            'in a story.' % (exploration.init_state_name, exploration.id))
        init_state = exploration.states[exploration.init_state_name]
        init_state.update_interaction_id('TextInput')
        solution_dict: state_domain.SolutionDict = {
            'answer_is_exclusive': False,
            'correct_answer': 'helloworld!',
            'explanation': {
                'content_id': 'solution',
                'html': (
                    '<oppia-noninteractive-collapsible content-with-value='
                    '"&amp;quot;&amp;lt;p&amp;gt;Hello&amp;lt;/p&amp;gt;&amp;'
                    'quot;" heading-with-value="&amp;quot;SubCollapsible&amp;'
                    'quot;"></oppia-noninteractive-collapsible><p>&nbsp;</p>')
            },
        }
        # Ruling out the possibility of None for mypy type checking.
        assert init_state.interaction.id is not None
        solution = state_domain.Solution.from_dict(
            init_state.interaction.id, solution_dict
        )
        init_state.update_interaction_solution(solution)
        exploration.states[exploration.init_state_name] = init_state
        errors = exp_services.validate_exploration_for_story(
            exploration, False)
        self.assertEqual(len(errors), 1)
        self.assertEqual(errors[0], error_string)
        with self.assertRaisesRegex(utils.ValidationError, error_string):
            exp_services.validate_exploration_for_story(
                exploration, True)

    def test_validation_fail_for_state_classifier_model(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, category='Algebra')
        exploration.states[
            feconf.DEFAULT_INIT_STATE_NAME].classifier_model_id = '2'
        error_string = (
            'Explorations in a story are not expected to contain '
            'classifier models. State %s of exploration with ID %s '
            'contains classifier models.' % (
                feconf.DEFAULT_INIT_STATE_NAME, exploration.id
            ))
        errors = exp_services.validate_exploration_for_story(
            exploration, False)
        self.assertEqual(len(errors), 1)
        self.assertEqual(errors[0], error_string)
        with self.assertRaisesRegex(
            utils.ValidationError, error_string):
            exp_services.validate_exploration_for_story(
                exploration, True)

    def test_validation_fail_for_answer_groups(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, category='Algebra')
        exploration.states[
            feconf.DEFAULT_INIT_STATE_NAME
        ].interaction.answer_groups = [state_domain.AnswerGroup(
            state_domain.Outcome(
                'state 1', None, state_domain.SubtitledHtml(
                    'feedback_1', '<p>state outcome html</p>'),
                False, [], None, None),
            [
                state_domain.RuleSpec(
                    'Equals', {
                        'x': {
                            'contentId': 'rule_input_Equals',
                            'normalizedStrSet': ['Test']
                        }
                    }
                )
            ],
            [
                'cheerful',
                'merry',
                'ecstatic',
                'glad',
                'overjoyed',
                'pleased',
                'thrilled',
                'smile'
            ],
            None
        )]
        error_string = (
            'Explorations in a story are not expected to contain '
            'training data for any answer group. State %s of '
            'exploration with ID %s contains training data in one of '
            'its answer groups.' % (
                feconf.DEFAULT_INIT_STATE_NAME, exploration.id
            )
        )
        errors = exp_services.validate_exploration_for_story(
            exploration, False)
        self.assertEqual(len(errors), 1)
        self.assertEqual(errors[0], error_string)
        with self.assertRaisesRegex(
            utils.ValidationError, error_string):
            exp_services.validate_exploration_for_story(
                exploration, True)

    def test_validation_fail_for_default_outcome(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, category='Algebra')
        exploration.states[
            feconf.DEFAULT_INIT_STATE_NAME
        ].interaction.default_outcome = (
            state_domain.Outcome(
                'state 1', None, state_domain.SubtitledHtml(
                    'default_outcome', '<p>Default outcome for state 4</p>'
                ), False, [param_domain.ParamChange(
                    'ParamChange', 'RandomSelector', {
                        'list_of_values': ['3', '4'],
                        'parse_with_jinja': True
                    }
                )], None, None
            )
        )
        error_string = (
            'Explorations in a story are not expected to contain '
            'parameter values. State %s of exploration with ID %s '
            'contains parameter values in its default outcome.' % (
                feconf.DEFAULT_INIT_STATE_NAME, exploration.id
            )
        )
        errors = exp_services.validate_exploration_for_story(
            exploration, False)
        self.assertEqual(len(errors), 1)
        self.assertEqual(errors[0], error_string)
        with self.assertRaisesRegex(
            utils.ValidationError, error_string):
            exp_services.validate_exploration_for_story(
                exploration, True)

    def test_update_exploration_by_migration_bot(self) -> None:
        self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, end_state_name='end')
        rights_manager.publish_exploration(self.owner, self.EXP_0_ID)

        exp_services.update_exploration(
            feconf.MIGRATION_BOT_USER_ID, self.EXP_0_ID, [
                exp_domain.ExplorationChange({
                    'cmd': 'edit_exploration_property',
                    'property_name': 'title',
                    'new_value': 'New title'
                })], 'Did migration.')

    def test_update_exploration_by_migration_bot_not_updates_contribution_model(
        self
    ) -> None:
        self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, end_state_name='end')
        rights_manager.publish_exploration(self.owner, self.EXP_0_ID)

        migration_bot_contributions_model = (
            user_services.get_user_contributions(feconf.MIGRATION_BOT_USER_ID))
        self.assertIsNone(migration_bot_contributions_model)

        exp_services.update_exploration(
            feconf.MIGRATION_BOT_USER_ID, self.EXP_0_ID, [
                exp_domain.ExplorationChange({
                    'cmd': 'edit_exploration_property',
                    'property_name': 'title',
                    'new_value': 'New title'
                })], 'Did migration.')

        migration_bot_contributions_model = (
            user_services.get_user_contributions(feconf.MIGRATION_BOT_USER_ID))
        self.assertIsNone(migration_bot_contributions_model)

    def test_update_exploration_by_migration_bot_not_updates_settings_model(
        self
    ) -> None:
        self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, end_state_name='end')
        rights_manager.publish_exploration(self.owner, self.EXP_0_ID)

        exp_services.update_exploration(
            feconf.MIGRATION_BOT_USER_ID, self.EXP_0_ID, [
                exp_domain.ExplorationChange({
                    'cmd': 'edit_exploration_property',
                    'property_name': 'title',
                    'new_value': 'New title'
                })], 'Did migration.')

        migration_bot_settings_model = (
            user_services.get_user_settings_from_username(
                feconf.MIGRATION_BOT_USERNAME))
        self.assertEqual(migration_bot_settings_model, None)

    def test_get_multiple_explorations_from_model_by_id(self) -> None:
        self.save_new_valid_exploration(
            'exp_id_1', self.owner_id, title='title 1',
            category='category 1', objective='objective 1')
        self.save_new_valid_exploration(
            'exp_id_2', self.owner_id, title='title 2',
            category='category 2', objective='objective 2')

        explorations = exp_fetchers.get_multiple_explorations_by_id(
            ['exp_id_1', 'exp_id_2'])

        self.assertEqual(len(explorations), 2)
        self.assertEqual(explorations['exp_id_1'].title, 'title 1')
        self.assertEqual(explorations['exp_id_1'].category, 'category 1')
        self.assertEqual(
            explorations['exp_id_1'].objective, 'objective 1')

        self.assertEqual(explorations['exp_id_2'].title, 'title 2')
        self.assertEqual(explorations['exp_id_2'].category, 'category 2')
        self.assertEqual(
            explorations['exp_id_2'].objective, 'objective 2')

    def test_cannot_get_interaction_ids_mapping_by_version_with_invalid_handler(
        self
    ) -> None:
        rights_manager.create_new_exploration_rights(
            'exp_id_1', self.owner_id)

        states_dict = {
            feconf.DEFAULT_INIT_STATE_NAME: {
                'content': [{'type': 'text', 'value': ''}],
                'param_changes': [],
                'interaction': {
                    'customization_args': {},
                    'id': 'Continue',
                    'handlers': [{
                        'name': 'invalid_handler_name',
                        'rule_specs': [{
                            'dest': 'END',
                            'dest_if_really_stuck': None,
                            'feedback': [],
                            'param_changes': [],
                            'definition': {'rule_type': 'default'}
                        }]
                    }]
                },
            }
        }

        exploration_model = exp_models.ExplorationModel(
            id='exp_id_1',
            category='category 1',
            title='title 1',
            objective='objective 1',
            init_state_name=feconf.DEFAULT_INIT_STATE_NAME,
            states_schema_version=3,
            states=states_dict
        )

        exploration_model.commit(
            self.owner_id, 'exploration model created',
            [{
                'cmd': 'create',
                'title': 'title 1',
                'category': 'category 1',
            }])

        with self.assertRaisesRegex(
            Exception,
            re.escape(
                'Exploration(id=exp_id_1, version=1, states_schema_version=3) '
                'does not match the latest schema version %s'
                % feconf.CURRENT_STATE_SCHEMA_VERSION)):
            (
                exp_fetchers
                .get_multiple_versioned_exp_interaction_ids_mapping_by_version(
                    'exp_id_1', [1]))


class LoadingAndDeletionOfExplorationDemosTests(ExplorationServicesUnitTests):

    def test_loading_and_validation_and_deletion_of_demo_explorations(
        self
    ) -> None:
        """Test loading, validation and deletion of the demo explorations."""
        self.assertEqual(
            exp_models.ExplorationModel.get_exploration_count(), 0)

        demo_exploration_ids = list(feconf.DEMO_EXPLORATIONS.keys())
        self.assertGreaterEqual(
            len(demo_exploration_ids), 1,
            msg='There must be at least one demo exploration.')

        for exp_id in demo_exploration_ids:
            start_time = datetime.datetime.utcnow()

            exp_services.load_demo(exp_id)
            exploration = exp_fetchers.get_exploration_by_id(exp_id)
            exploration.validate(strict=True)

            duration = datetime.datetime.utcnow() - start_time
            processing_time = duration.seconds + (duration.microseconds / 1E6)
            self.log_line(
                'Loaded and validated exploration %s (%.2f seconds)' %
                (exploration.title, processing_time))

        self.assertEqual(
            exp_models.ExplorationModel.get_exploration_count(),
            len(demo_exploration_ids))

        for exp_id in demo_exploration_ids:
            exp_services.delete_demo(exp_id)
        self.assertEqual(
            exp_models.ExplorationModel.get_exploration_count(), 0)

    def test_load_demo_with_invalid_demo_exploration_id_raises_error(
        self
    ) -> None:
        with self.assertRaisesRegex(
            Exception, 'Invalid demo exploration id invalid_exploration_id'):
            exp_services.load_demo('invalid_exploration_id')

    def test_delete_demo_with_invalid_demo_exploration_id_raises_error(
        self
    ) -> None:
        with self.assertRaisesRegex(
            Exception, 'Invalid demo exploration id invalid_exploration_id'):
            exp_services.delete_demo('invalid_exploration_id')


class ExplorationYamlImportingTests(test_utils.GenericTestBase):
    """Tests for loading explorations using imported YAML."""

    EXP_ID: Final = 'exp_id0'
    DEMO_EXP_ID: Final = '0'
    TEST_ASSET_PATH: Final = 'test_asset.txt'
    TEST_ASSET_CONTENT: Final = b'Hello Oppia'

    INTRO_AUDIO_FILE: Final = 'introduction_state.mp3'
    ANSWER_GROUP_AUDIO_FILE: Final = 'correct_answer_feedback.mp3'
    DEFAULT_OUTCOME_AUDIO_FILE: Final = 'unknown_answer_feedback.mp3'
    HINT_AUDIO_FILE: Final = 'answer_hint.mp3'
    SOLUTION_AUDIO_FILE: Final = 'answer_solution.mp3'

    YAML_WITH_AUDIO_TRANSLATIONS: str = (
        """author_notes: ''
auto_tts_enabled: true
blurb: ''
category: Category
edits_allowed: true
init_state_name: Introduction
language_code: en
objective: ''
param_changes: []
param_specs: {}
schema_version: 47
states:
  Introduction:
    classifier_model_id: null
    content:
      content_id: content
      html: ''
    interaction:
      answer_groups:
      - outcome:
          dest: New state
          dest_if_really_stuck: null
          feedback:
            content_id: feedback_1
            html: <p>Correct!</p>
          labelled_as_correct: false
          missing_prerequisite_skill_id: null
          param_changes: []
          refresher_exploration_id: null
        rule_specs:
        - inputs:
            x:
              contentId: rule_input_3
              normalizedStrSet:
              - InputString
          rule_type: Equals
        tagged_skill_misconception_id: null
        training_data: []
      confirmed_unclassified_answers: []
      customization_args:
        placeholder:
          value:
            content_id: ca_placeholder_2
            unicode_str: ''
        rows:
          value: 1
        catchMisspellings:
          value: false
      default_outcome:
        dest: Introduction
        dest_if_really_stuck: null
        feedback:
          content_id: default_outcome
          html: ''
        labelled_as_correct: false
        missing_prerequisite_skill_id: null
        param_changes: []
        refresher_exploration_id: null
      hints:
      - hint_content:
          content_id: hint_1
          html: <p>hint one,</p>
      id: TextInput
      solution:
        answer_is_exclusive: false
        correct_answer: helloworld!
        explanation:
          content_id: solution
          html: <p>hello_world is a string</p>
    linked_skill_id: null
    next_content_id_index: 4
    param_changes: []
    recorded_voiceovers:
      voiceovers_mapping:
        ca_placeholder_2: {}
        content:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: %s
            needs_update: false
        default_outcome:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: %s
            needs_update: false
        feedback_1:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: %s
            needs_update: false
        hint_1:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: %s
            needs_update: false
        rule_input_3: {}
        solution:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: %s
            needs_update: false
    solicit_answer_details: false
    card_is_checkpoint: true
    written_translations:
      translations_mapping:
        ca_placeholder_2: {}
        content: {}
        default_outcome: {}
        feedback_1: {}
        hint_1: {}
        rule_input_3: {}
        solution: {}
  New state:
    classifier_model_id: null
    content:
      content_id: content
      html: ''
    interaction:
      answer_groups: []
      confirmed_unclassified_answers: []
      customization_args:
        customization_args:
        placeholder:
          value:
            content_id: ca_placeholder_2
            unicode_str: ''
        rows:
          value: 1
      default_outcome:
        dest: New state
        dest_if_really_stuck: null
        feedback:
          content_id: default_outcome
          html: ''
        labelled_as_correct: false
        missing_prerequisite_skill_id: null
        param_changes: []
        refresher_exploration_id: null
      hints: []
      id: TextInput
      solution: null
    linked_skill_id: null
    next_content_id_index: 0
    param_changes: []
    recorded_voiceovers:
      voiceovers_mapping:
        content: {}
        default_outcome: {}
        ca_placeholder_2: {}
    solicit_answer_details: false
    card_is_checkpoint: false
    written_translations:
      translations_mapping:
        content: {}
        default_outcome: {}
        ca_placeholder_2: {}
states_schema_version: 42
tags: []
title: Title
""") % (
    INTRO_AUDIO_FILE, DEFAULT_OUTCOME_AUDIO_FILE, ANSWER_GROUP_AUDIO_FILE,
    HINT_AUDIO_FILE, SOLUTION_AUDIO_FILE)

    def setUp(self) -> None:
        super().setUp()
        self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME)
        self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)

    def test_loading_recent_yaml_loads_exploration_for_user(self) -> None:
        exp_services.save_new_exploration_from_yaml_and_assets(
            self.owner_id, self.SAMPLE_YAML_CONTENT, self.EXP_ID, [])
        exp = exp_fetchers.get_exploration_by_id(self.EXP_ID)
        self.assertEqual(exp.to_yaml(), self.SAMPLE_YAML_CONTENT)

    def test_loading_recent_yaml_does_not_default_exp_title_category(
        self
    ) -> None:
        exp_services.save_new_exploration_from_yaml_and_assets(
            self.owner_id, self.SAMPLE_YAML_CONTENT, self.EXP_ID, [])
        exp = exp_fetchers.get_exploration_by_id(self.EXP_ID)
        self.assertNotEqual(exp.title, feconf.DEFAULT_EXPLORATION_TITLE)
        self.assertNotEqual(exp.category, feconf.DEFAULT_EXPLORATION_CATEGORY)

    def test_loading_yaml_with_assets_loads_assets_from_filesystem(
        self
    ) -> None:
        test_asset = (self.TEST_ASSET_PATH, self.TEST_ASSET_CONTENT)
        exp_services.save_new_exploration_from_yaml_and_assets(
            self.owner_id, self.SAMPLE_YAML_CONTENT, self.EXP_ID, [test_asset])

        fs = fs_services.GcsFileSystem(
            feconf.ENTITY_TYPE_EXPLORATION, self.EXP_ID)
        self.assertEqual(
            fs.get(self.TEST_ASSET_PATH), self.TEST_ASSET_CONTENT)

    def test_can_load_yaml_with_voiceovers(self) -> None:
        exp_services.save_new_exploration_from_yaml_and_assets(
            self.owner_id, self.YAML_WITH_AUDIO_TRANSLATIONS, self.EXP_ID, [])
        exp = exp_fetchers.get_exploration_by_id(self.EXP_ID)

        state = exp.states[exp.init_state_name]
        interaction = state.interaction
        # Ruling out the possibility of None for mypy type checking.
        assert interaction.solution is not None
        assert interaction.default_outcome is not None
        content_id = state.content.content_id
        voiceovers_mapping = state.recorded_voiceovers.voiceovers_mapping
        content_voiceovers = voiceovers_mapping[content_id]
        feedback_id = interaction.answer_groups[0].outcome.feedback.content_id
        answer_group_voiceovers = voiceovers_mapping[feedback_id]
        default_outcome_id = interaction.default_outcome.feedback.content_id
        default_outcome_voiceovers = voiceovers_mapping[default_outcome_id]
        hint_id = interaction.hints[0].hint_content.content_id
        hint_voiceovers = voiceovers_mapping[hint_id]
        solution_id = interaction.solution.explanation.content_id
        solution_voiceovers = voiceovers_mapping[solution_id]

        self.assertEqual(
            content_voiceovers['en'].filename, self.INTRO_AUDIO_FILE)
        self.assertEqual(
            answer_group_voiceovers['en'].filename,
            self.ANSWER_GROUP_AUDIO_FILE)
        self.assertEqual(
            default_outcome_voiceovers['en'].filename,
            self.DEFAULT_OUTCOME_AUDIO_FILE)
        self.assertEqual(hint_voiceovers['en'].filename, self.HINT_AUDIO_FILE)
        self.assertEqual(
            solution_voiceovers['en'].filename, self.SOLUTION_AUDIO_FILE)

    def test_can_load_yaml_with_stripped_voiceovers(self) -> None:
        exp_services.save_new_exploration_from_yaml_and_assets(
            self.owner_id, self.YAML_WITH_AUDIO_TRANSLATIONS, self.EXP_ID, [],
            strip_voiceovers=True)
        exp = exp_fetchers.get_exploration_by_id(self.EXP_ID)

        state = exp.states[exp.init_state_name]
        interaction = state.interaction
        # Ruling out the possibility of None for mypy type checking.
        assert interaction.solution is not None
        assert interaction.default_outcome is not None
        content_id = state.content.content_id
        voiceovers_mapping = state.recorded_voiceovers.voiceovers_mapping
        content_voiceovers = voiceovers_mapping[content_id]
        feedback_id = interaction.answer_groups[0].outcome.feedback.content_id
        answer_group_voiceovers = voiceovers_mapping[feedback_id]
        default_outcome_id = interaction.default_outcome.feedback.content_id
        default_outcome_voiceovers = voiceovers_mapping[default_outcome_id]
        hint_id = interaction.hints[0].hint_content.content_id
        hint_voiceovers = voiceovers_mapping[hint_id]
        solution_id = interaction.solution.explanation.content_id
        solution_voiceovers = voiceovers_mapping[solution_id]

        self.assertEqual(content_voiceovers, {})
        self.assertEqual(answer_group_voiceovers, {})
        self.assertEqual(default_outcome_voiceovers, {})
        self.assertEqual(hint_voiceovers, {})
        self.assertEqual(solution_voiceovers, {})

    def test_cannot_load_yaml_with_no_schema_version(self) -> None:
        yaml_with_no_schema_version = (
            """
        author_notes: ''
        auto_tts_enabled: true
        blurb: ''
        category: Category    
        edits_allowed: true
        init_state_name: Introduction
        language_code: en
        objective: ''
        param_changes: []
        param_specs: {}
        states:
          Introduction:
            classifier_model_id: null
            content:
              audio_translations:
                en:
                    filename: %s
                    file_size_bytes: 99999
                    needs_update: false
              html: ''
            interaction:
              answer_groups:
              - outcome:
                  dest: New state
                  dest_if_really_stuck: null
                  feedback:
                    audio_translations:
                        en:
                            filename: %s
                            file_size_bytes: 99999
                            needs_update: false
                    html: Correct!
                  labelled_as_correct: false
                  missing_prerequisite_skill_id: null
                  param_changes: []
                  refresher_exploration_id: null
                rule_specs:
                - inputs:
                    x: InputString
                  rule_type: Equals
              confirmed_unclassified_answers: []
              customization_args: {}
              default_outcome:
                dest: Introduction
                dest_if_really_stuck: null
                feedback:
                  audio_translations:
                    en:
                        filename: %s
                        file_size_bytes: 99999
                        needs_update: false
                  html: ''
                labelled_as_correct: false
                missing_prerequisite_skill_id: null
                param_changes: []
                refresher_exploration_id: null
              hints:
                - hint_content:
                    html: hint one,
                    audio_translations:
                        en:
                            filename: %s
                            file_size_bytes: 99999
                            needs_update: false
              id: TextInput
              solution:
                answer_is_exclusive: false
                correct_answer: helloworld!
                explanation:
                    html: hello_world is a string
                    audio_translations:
                        en:
                            filename: %s
                            file_size_bytes: 99999
                            needs_update: false
            param_changes: []
          New state:
            classifier_model_id: null
            content:
              audio_translations: {}
              html: ''
            interaction:
              answer_groups: []
              confirmed_unclassified_answers: []
              customization_args: {}
              default_outcome:
                dest: New state
                dest_if_really_stuck: null
                feedback:
                  audio_translations: {}
                  html: ''
                labelled_as_correct: false
                missing_prerequisite_skill_id: null
                param_changes: []
                refresher_exploration_id: null
              hints: []
              id: null
              solution: null
            param_changes: []
        states_schema_version: 18
        tags: []
        title: Title
        """) % (
            self.INTRO_AUDIO_FILE, self.ANSWER_GROUP_AUDIO_FILE,
            self.DEFAULT_OUTCOME_AUDIO_FILE,
            self.HINT_AUDIO_FILE, self.SOLUTION_AUDIO_FILE)

        with self.assertRaisesRegex(
            Exception, 'Invalid YAML file: missing schema version'):
            exp_services.save_new_exploration_from_yaml_and_assets(
                self.owner_id, yaml_with_no_schema_version, self.EXP_ID, [])


class GetImageFilenamesFromExplorationTests(ExplorationServicesUnitTests):

    def test_get_image_filenames_from_exploration(self) -> None:
        exploration = exp_domain.Exploration.create_default_exploration(
            'eid', title='title', category='category')
        exploration.add_states(['state1', 'state2', 'state3'])
        state1 = exploration.states['state1']
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        state2 = exploration.states['state2']
        state3 = exploration.states['state3']
        content1_dict: state_domain.SubtitledHtmlDict = {
            'content_id': content_id_generator.generate(
                translation_domain.ContentType.CONTENT),
            'html': (
                '<blockquote>Hello, this is state1</blockquote>'
                '<oppia-noninteractive-image filepath-with-value='
                '"&amp;quot;s1Content.png&amp;quot;" caption-with-value='
                '"&amp;quot;&amp;quot;" alt-with-value="&amp;quot;image>'
                '&amp;quot;"</oppia-noninteractive-image>')
        }
        content2_dict: state_domain.SubtitledHtmlDict = {
            'content_id': content_id_generator.generate(
                translation_domain.ContentType.CONTENT),
            'html': '<pre>Hello, this is state2</pre>'
        }
        content3_dict: state_domain.SubtitledHtmlDict = {
            'content_id': content_id_generator.generate(
                translation_domain.ContentType.CONTENT),
            'html': '<p>Hello, this is state3</p>'
        }
        state1.update_content(
            state_domain.SubtitledHtml.from_dict(content1_dict))
        state2.update_content(
            state_domain.SubtitledHtml.from_dict(content2_dict))
        state3.update_content(
            state_domain.SubtitledHtml.from_dict(content3_dict))

        self.set_interaction_for_state(
            state1, 'ImageClickInput', content_id_generator)
        self.set_interaction_for_state(
            state2, 'MultipleChoiceInput', content_id_generator)
        self.set_interaction_for_state(
            state3, 'ItemSelectionInput', content_id_generator)

        customization_args_dict1: Dict[
            str, Dict[str, Union[bool, domain.ImageAndRegionDict]]
        ] = {
            'highlightRegionsOnHover': {'value': True},
            'imageAndRegions': {
                'value': {
                    'imagePath': 's1ImagePath.png',
                    'labeledRegions': [{
                        'label': 'classdef',
                        'region': {
                            'area': [
                                [0.004291845493562232, 0.004692192192192192],
                                [0.40987124463519314, 0.05874624624624625]
                            ],
                            'regionType': 'Rectangle'
                        }
                    }]
                }
            }
        }
        customization_args_choices: List[state_domain.SubtitledHtmlDict] = [{
                'content_id': content_id_generator.generate(
                    translation_domain.ContentType.CUSTOMIZATION_ARG,
                    extra_prefix='choices'),
                'html': (
                    '<p>This is value1 for MultipleChoice'
                    '<oppia-noninteractive-image filepath-with-value='
                    '"&amp;quot;s2Choice1.png&amp;quot;" caption-with-value='
                    '"&amp;quot;&amp;quot;" alt-with-value="&amp;quot;'
                    'image&amp;quot;"></oppia-noninteractive-image></p>'
                )
            }, {
                'content_id': content_id_generator.generate(
                    translation_domain.ContentType.CUSTOMIZATION_ARG,
                    extra_prefix='choices'),
                'html': (
                    '<p>This is value2 for MultipleChoice'
                    '<oppia-noninteractive-image filepath-with-value='
                    '"&amp;quot;s2Choice2.png&amp;quot;" caption-with-value='
                    '"&amp;quot;&amp;quot;" alt-with-value='
                    '"&amp;quot;image&amp;quot;"></oppia-noninteractive-image>'
                    '</p></p>')
            }]
        customization_args_dict2: Dict[
            str, Dict[str, Union[bool, List[state_domain.SubtitledHtmlDict]]]
        ] = {
            'choices': {'value': customization_args_choices},
            'showChoicesInShuffledOrder': {'value': True}
        }
        customization_args_choices = [{
            'content_id': content_id_generator.generate(
                translation_domain.ContentType.CUSTOMIZATION_ARG,
                extra_prefix='choices'),
            'html': (
                '<p>This is value1 for ItemSelection'
                '<oppia-noninteractive-image filepath-with-value='
                '"&amp;quot;s3Choice1.png&amp;quot;" caption-with-value='
                '"&amp;quot;&amp;quot;" alt-with-value='
                '"&amp;quot;image&amp;quot;"></oppia-noninteractive-image>'
                '</p>')
        }, {
            'content_id': content_id_generator.generate(
                translation_domain.ContentType.CUSTOMIZATION_ARG,
                extra_prefix='choices'),
            'html': (
                '<p>This is value2 for ItemSelection'
                '<oppia-noninteractive-image filepath-with-value='
                '"&amp;quot;s3Choice2.png&amp;quot;" caption-with-value='
                '"&amp;quot;&amp;quot;" alt-with-value='
                '"&amp;quot;image&amp;quot;"></oppia-noninteractive-image>'
                '</p>')
        }, {
            'content_id': content_id_generator.generate(
                translation_domain.ContentType.CUSTOMIZATION_ARG,
                extra_prefix='choices'),
            'html': (
                '<p>This is value3 for ItemSelection'
                '<oppia-noninteractive-image filepath-with-value='
                '"&amp;quot;s3Choice3.png&amp;quot;" caption-with-value='
                '"&amp;quot;&amp;quot;" alt-with-value='
                '"&amp;quot;image&amp;quot;"></oppia-noninteractive-image>'
                '</p>')
        }]
        customization_args_dict3: Dict[
            str, Dict[str, Union[int, List[state_domain.SubtitledHtmlDict]]]
        ] = {
            'choices': {'value': customization_args_choices},
            'minAllowableSelectionCount': {'value': 1},
            'maxAllowableSelectionCount': {'value': 5}
        }
        state1.update_interaction_customization_args(customization_args_dict1)
        state2.update_interaction_customization_args(customization_args_dict2)
        state3.update_interaction_customization_args(customization_args_dict3)

        default_outcome1 = state_domain.Outcome(
            'state2', None, state_domain.SubtitledHtml(
                content_id_generator.generate(
                    translation_domain.ContentType.DEFAULT_OUTCOME),
                '<p>Default outcome for state1</p>'
            ), False, [], None, None
        )
        state1.update_interaction_default_outcome(default_outcome1)

        hint_list2 = [
            state_domain.Hint(
                state_domain.SubtitledHtml(
                    content_id_generator.generate(
                        translation_domain.ContentType.HINT),
                    (
                        '<p>Hello, this is html1 for state2</p>'
                        '<oppia-noninteractive-image filepath-with-value="'
                        '&amp;quot;s2Hint1.png&amp;quot;" caption-with-value='
                        '"&amp;quot;&amp;quot;" alt-with-value="&amp;quot;'
                        'image&amp;quot;"></oppia-noninteractive-image>'
                    )
                )
            ),
            state_domain.Hint(
                state_domain.SubtitledHtml(
                    content_id_generator.generate(
                        translation_domain.ContentType.HINT),
                    '<p>Hello, this is html2 for state2</p>')
            ),
        ]
        state2.update_interaction_hints(hint_list2)

        state_answer_group_list2 = [state_domain.AnswerGroup(
            state_domain.Outcome(
                'state1', None, state_domain.SubtitledHtml(
                    content_id_generator.generate(
                        translation_domain.ContentType.FEEDBACK), (
                        '<p>Outcome1 for state2</p><oppia-noninteractive-image'
                        ' filepath-with-value='
                        '"&amp;quot;s2AnswerGroup.png&amp;quot;"'
                        ' caption-with-value="&amp;quot;&amp;quot;"'
                        ' alt-with-value="&amp;quot;image&amp;quot;">'
                        '</oppia-noninteractive-image>')
                    ), False, [], None, None), [
                        state_domain.RuleSpec('Equals', {'x': 0}),
                        state_domain.RuleSpec('Equals', {'x': 1})
                    ], [], None
        ), state_domain.AnswerGroup(
            state_domain.Outcome(
                'state3', None, state_domain.SubtitledHtml(
                    content_id_generator.generate(
                        translation_domain.ContentType.FEEDBACK),
                    '<p>Outcome2 for state2</p>'),
                False, [], None, None),
            [
                state_domain.RuleSpec('Equals', {'x': 0})
            ],
            [],
            None
        )]
        state_answer_group_list3 = [state_domain.AnswerGroup(
            state_domain.Outcome(
                'state1', None, state_domain.SubtitledHtml(
                    content_id_generator.generate(
                        translation_domain.ContentType.FEEDBACK),
                    '<p>Outcome for state3</p>'),
                False, [], None, None),
            [
                state_domain.RuleSpec(
                    'Equals', {
                        'x':
                        [(
                            '<p>This is value1 for ItemSelection</p>'
                            '<oppia-noninteractive-image filepath-with-'
                            'value='
                            '"&amp;quot;s3Choice1.png&amp;quot;"'
                            ' caption-with-value="&amp;quot;&amp;quot;" '
                            'alt-with-value="&amp;quot;image&amp;quot;">'
                            '</oppia-noninteractive-image>')
                        ]}),
                state_domain.RuleSpec(
                    'Equals', {
                        'x':
                        [(
                            '<p>This is value3 for ItemSelection</p>'
                            '<oppia-noninteractive-image filepath-with-'
                            'value='
                            '"&amp;quot;s3Choice3.png&amp;quot;"'
                            ' caption-with-value="&amp;quot;&amp;quot;" '
                            'alt-with-value="&amp;quot;image&amp;quot;">'
                            '</oppia-noninteractive-image>')
                        ]})
            ],
            [],
            None
        )]

        state2.update_interaction_answer_groups(state_answer_group_list2)
        state3.update_interaction_answer_groups(state_answer_group_list3)

        exploration.update_next_content_id_index(
            content_id_generator.next_content_id_index)
        filenames = (
            exp_services.get_image_filenames_from_exploration(exploration))
        expected_output = ['s1ImagePath.png', 's1Content.png', 's2Choice1.png',
                           's2Choice2.png', 's3Choice1.png', 's3Choice2.png',
                           's3Choice3.png', 's2Hint1.png',
                           's2AnswerGroup.png']
        self.assertEqual(len(filenames), len(expected_output))
        for filename in expected_output:
            self.assertIn(filename, filenames)


class ZipFileExportUnitTests(ExplorationServicesUnitTests):
    """Test export methods for explorations represented as zip files."""

    DUMMY_IMAGE_TAG: Final = (
        '<oppia-noninteractive-image alt-with-value="&quot;Image&quot;" '
        'caption-with-value="&quot;&quot;"\n        filepath-with-value="'
        '&quot;abc.png&quot;"></oppia-noninteractive-image>'
    )
    SAMPLE_YAML_CONTENT: str = (
        """author_notes: ''
auto_tts_enabled: false
blurb: ''
category: Algebra
edits_allowed: true
init_state_name: %s
language_code: en
next_content_id_index: 6
objective: The objective
param_changes: []
param_specs: {}
schema_version: %d
states:
  %s:
    card_is_checkpoint: true
    classifier_model_id: null
    content:
      content_id: content_0
      html: ''
    interaction:
      answer_groups: []
      confirmed_unclassified_answers: []
      customization_args:
        catchMisspellings:
          value: false
        placeholder:
          value:
            content_id: ca_placeholder_2
            unicode_str: ''
        rows:
          value: 1
      default_outcome:
        dest: %s
        dest_if_really_stuck: null
        feedback:
          content_id: default_outcome_1
          html: ''
        labelled_as_correct: false
        missing_prerequisite_skill_id: null
        param_changes: []
        refresher_exploration_id: null
      hints: []
      id: TextInput
      solution: null
    linked_skill_id: null
    param_changes: []
    recorded_voiceovers:
      voiceovers_mapping:
        ca_placeholder_2: {}
        content_0: {}
        default_outcome_1: {}
    solicit_answer_details: false
  New state:
    card_is_checkpoint: false
    classifier_model_id: null
    content:
      content_id: content_3
      html: %s
    interaction:
      answer_groups: []
      confirmed_unclassified_answers: []
      customization_args:
        catchMisspellings:
          value: false
        placeholder:
          value:
            content_id: ca_placeholder_5
            unicode_str: ''
        rows:
          value: 1
      default_outcome:
        dest: New state
        dest_if_really_stuck: null
        feedback:
          content_id: default_outcome_4
          html: ''
        labelled_as_correct: false
        missing_prerequisite_skill_id: null
        param_changes: []
        refresher_exploration_id: null
      hints: []
      id: TextInput
      solution: null
    linked_skill_id: null
    param_changes: []
    recorded_voiceovers:
      voiceovers_mapping:
        ca_placeholder_5: {}
        content_3: {}
        default_outcome_4: {}
    solicit_answer_details: false
states_schema_version: %d
tags: []
title: A title
version: 2
""" % (
    feconf.DEFAULT_INIT_STATE_NAME,
    exp_domain.Exploration.CURRENT_EXP_SCHEMA_VERSION,
    feconf.DEFAULT_INIT_STATE_NAME,
    feconf.DEFAULT_INIT_STATE_NAME,
    DUMMY_IMAGE_TAG,
    feconf.CURRENT_STATE_SCHEMA_VERSION))

    UPDATED_YAML_CONTENT = (
        """author_notes: ''
auto_tts_enabled: false
blurb: ''
category: Algebra
edits_allowed: true
init_state_name: %s
language_code: en
next_content_id_index: 6
objective: The objective
param_changes: []
param_specs: {}
schema_version: %d
states:
  %s:
    card_is_checkpoint: true
    classifier_model_id: null
    content:
      content_id: content_0
      html: ''
    interaction:
      answer_groups: []
      confirmed_unclassified_answers: []
      customization_args:
        catchMisspellings:
          value: false
        placeholder:
          value:
            content_id: ca_placeholder_2
            unicode_str: ''
        rows:
          value: 1
      default_outcome:
        dest: %s
        dest_if_really_stuck: null
        feedback:
          content_id: default_outcome_1
          html: ''
        labelled_as_correct: false
        missing_prerequisite_skill_id: null
        param_changes: []
        refresher_exploration_id: null
      hints: []
      id: TextInput
      solution: null
    linked_skill_id: null
    param_changes: []
    recorded_voiceovers:
      voiceovers_mapping:
        ca_placeholder_2: {}
        content_0: {}
        default_outcome_1: {}
    solicit_answer_details: false
  Renamed state:
    card_is_checkpoint: false
    classifier_model_id: null
    content:
      content_id: content_3
      html: %s
    interaction:
      answer_groups: []
      confirmed_unclassified_answers: []
      customization_args:
        catchMisspellings:
          value: false
        placeholder:
          value:
            content_id: ca_placeholder_5
            unicode_str: ''
        rows:
          value: 1
      default_outcome:
        dest: Renamed state
        dest_if_really_stuck: null
        feedback:
          content_id: default_outcome_4
          html: ''
        labelled_as_correct: false
        missing_prerequisite_skill_id: null
        param_changes: []
        refresher_exploration_id: null
      hints: []
      id: TextInput
      solution: null
    linked_skill_id: null
    param_changes: []
    recorded_voiceovers:
      voiceovers_mapping:
        ca_placeholder_5: {}
        content_3: {}
        default_outcome_4: {}
    solicit_answer_details: false
states_schema_version: %d
tags: []
title: A title
version: 3
""" % (
    feconf.DEFAULT_INIT_STATE_NAME,
    exp_domain.Exploration.CURRENT_EXP_SCHEMA_VERSION,
    feconf.DEFAULT_INIT_STATE_NAME,
    feconf.DEFAULT_INIT_STATE_NAME,
    DUMMY_IMAGE_TAG,
    feconf.CURRENT_STATE_SCHEMA_VERSION))

    def test_export_to_zip_file(self) -> None:
        """Test the export_to_zip_file() method."""
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, objective='The objective',
            category='Algebra')
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        init_state = exploration.states[exploration.init_state_name]
        # Ruling out the possibility of None for mypy type checking.
        assert init_state.interaction.default_outcome is not None
        default_outcome_dict = init_state.interaction.default_outcome.to_dict()
        default_outcome_dict['dest'] = exploration.init_state_name
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name': (
                        exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME),
                    'state_name': exploration.init_state_name,
                    'new_value': default_outcome_dict
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_ADD_STATE,
                    'state_name': 'New state',
                    'content_id_for_state_content': (
                        content_id_generator.generate(
                            translation_domain.ContentType.CONTENT)
                    ),
                    'content_id_for_default_outcome': (
                        content_id_generator.generate(
                            translation_domain.ContentType.DEFAULT_OUTCOME)
                    )
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
                    'state_name': 'New state',
                    'new_value': 'TextInput'
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name':
                        exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                    'state_name': 'New state',
                    'new_value': {
                        'placeholder': {
                            'value': {
                                'content_id': content_id_generator.generate((
                                        translation_domain
                                        .ContentType.CUSTOMIZATION_ARG),
                                    extra_prefix='placeholder'
                                    ),
                                'unicode_str': ''
                            }
                        },
                        'rows': {'value': 1},
                        'catchMisspellings': {'value': False}
                    }
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name': exp_domain.STATE_PROPERTY_CONTENT,
                    'state_name': 'New state',
                    'old_value': state_domain.SubtitledHtml(
                        'content_3', '').to_dict(),
                    'new_value': state_domain.SubtitledHtml(
                        'content_3',
                        '<oppia-noninteractive-image filepath-with-value='
                        '"&quot;abc.png&quot;" caption-with-value="&quot;'
                        '&quot;" alt-with-value="&quot;Image&quot;">'
                        '</oppia-noninteractive-image>').to_dict()
                }),
                exp_domain.ExplorationChange({
                    'cmd': 'edit_exploration_property',
                    'property_name': 'next_content_id_index',
                    'new_value': content_id_generator.next_content_id_index
                }), ], 'Add state name')

        with utils.open_file(
            os.path.join(feconf.TESTS_DATA_DIR, 'img.png'), 'rb',
            encoding=None) as f:
            raw_image = f.read()
        fs = fs_services.GcsFileSystem(
            feconf.ENTITY_TYPE_EXPLORATION, self.EXP_0_ID)
        fs.commit('image/abc.png', raw_image)
        zip_file_output = exp_services.export_to_zip_file(self.EXP_0_ID)
        zf = zipfile.ZipFile(zip_file_output)

        self.assertEqual(
            zf.namelist(), ['A title.yaml', 'assets/image/abc.png'])
        # Read function returns bytes, so we need to decode them before
        # we compare.
        self.assertEqual(
            zf.open('A title.yaml').read().decode('utf-8'),
            self.SAMPLE_YAML_CONTENT)

    def test_export_to_zip_file_with_unpublished_exploration(self) -> None:
        """Test the export_to_zip_file() method."""
        self.save_new_default_exploration(
            self.EXP_0_ID, self.owner_id, title='')

        zip_file_output = exp_services.export_to_zip_file(self.EXP_0_ID)
        zf = zipfile.ZipFile(zip_file_output)

        self.assertEqual(zf.namelist(), ['Unpublished_exploration.yaml'])

    def test_export_to_zip_file_with_a_nonstandard_char(self) -> None:
        """Test the export_to_zip_file() method with a nonstandard char."""
        self.save_new_default_exploration(
            self.EXP_0_ID, self.owner_id, title='What is a Fraction?')

        zip_file_output = exp_services.export_to_zip_file(self.EXP_0_ID)
        zf = zipfile.ZipFile(zip_file_output)

        self.assertEqual(zf.namelist(), ['What is a Fraction.yaml'])

    def test_export_to_zip_file_with_all_nonstandard_chars(self) -> None:
        """Test the export_to_zip_file() method with all nonstandard chars."""
        self.save_new_default_exploration(
            self.EXP_0_ID, self.owner_id, title='?!!!!!?')

        zip_file_output = exp_services.export_to_zip_file(self.EXP_0_ID)
        zf = zipfile.ZipFile(zip_file_output)

        self.assertEqual(zf.namelist(), ['exploration.yaml'])

    def test_export_to_zip_file_with_assets(self) -> None:
        """Test exporting an exploration with assets to a zip file."""
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, objective='The objective',
            category='Algebra')
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        init_state = exploration.states[exploration.init_state_name]
        # Ruling out the possibility of None for mypy type checking.
        assert init_state.interaction.default_outcome is not None
        default_outcome_dict = init_state.interaction.default_outcome.to_dict()
        default_outcome_dict['dest'] = exploration.init_state_name
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name': (
                        exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME),
                    'state_name': exploration.init_state_name,
                    'new_value': default_outcome_dict
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_ADD_STATE,
                    'state_name': 'New state',
                    'content_id_for_state_content': (
                        content_id_generator.generate(
                            translation_domain.ContentType.CONTENT)
                    ),
                    'content_id_for_default_outcome': (
                        content_id_generator.generate(
                            translation_domain.ContentType.DEFAULT_OUTCOME)
                    )
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
                    'state_name': 'New state',
                    'new_value': 'TextInput'
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name':
                        exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                    'state_name': 'New state',
                    'new_value': {
                        'placeholder': {
                            'value': {
                                'content_id': content_id_generator.generate((
                                        translation_domain
                                        .ContentType.CUSTOMIZATION_ARG),
                                    extra_prefix='placeholder'
                                    ),
                                'unicode_str': ''
                            }
                        },
                        'rows': {'value': 1},
                        'catchMisspellings': {'value': False}
                    }
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name': exp_domain.STATE_PROPERTY_CONTENT,
                    'state_name': 'New state',
                    'old_value': state_domain.SubtitledHtml(
                        'content_3', '').to_dict(),
                    'new_value': state_domain.SubtitledHtml(
                        'content_3',
                        '<oppia-noninteractive-image filepath-with-value='
                        '"&quot;abc.png&quot;" caption-with-value="'
                        '&quot;&quot;" alt-with-value="&quot;Image&quot;">'
                        '</oppia-noninteractive-image>').to_dict()
                }),
                exp_domain.ExplorationChange({
                    'cmd': 'edit_exploration_property',
                    'property_name': 'next_content_id_index',
                    'new_value': content_id_generator.next_content_id_index
                })], 'Add state name')

        with utils.open_file(
            os.path.join(feconf.TESTS_DATA_DIR, 'img.png'), 'rb', encoding=None
        ) as f:
            raw_image = f.read()
        fs = fs_services.GcsFileSystem(
            feconf.ENTITY_TYPE_EXPLORATION, self.EXP_0_ID)
        fs.commit('image/abc.png', raw_image)
        # Audio files should not be included in asset downloads.
        with utils.open_file(
            os.path.join(feconf.TESTS_DATA_DIR, 'cafe.mp3'), 'rb', encoding=None
        ) as f:
            raw_audio = f.read()
        fs.commit('audio/cafe.mp3', raw_audio)

        zip_file_output = exp_services.export_to_zip_file(self.EXP_0_ID)
        zf = zipfile.ZipFile(zip_file_output)

        self.assertEqual(
            zf.namelist(), ['A title.yaml', 'assets/image/abc.png'])
        # Read function returns bytes, so we need to decode them before
        # we compare.
        self.assertEqual(
            zf.open('A title.yaml').read().decode('utf-8'),
            self.SAMPLE_YAML_CONTENT)
        self.assertEqual(zf.open('assets/image/abc.png').read(), raw_image)

    def test_export_by_versions(self) -> None:
        """Test export_to_zip_file() for different versions."""
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, objective='The objective',
            category='Algebra')
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        self.assertEqual(exploration.version, 1)

        init_state = exploration.states[exploration.init_state_name]
        # Ruling out the possibility of None for mypy type checking.
        assert init_state.interaction.default_outcome is not None
        default_outcome_dict = init_state.interaction.default_outcome.to_dict()
        default_outcome_dict['dest'] = exploration.init_state_name
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name': (
                exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME),
            'state_name': exploration.init_state_name,
            'new_value': default_outcome_dict
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_ADD_STATE,
            'state_name': 'New state',
            'content_id_for_state_content': content_id_generator.generate(
                    translation_domain.ContentType.CONTENT),
            'content_id_for_default_outcome': (
                content_id_generator.generate(
                    translation_domain.ContentType.DEFAULT_OUTCOME)
            )
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'state_name': 'New state',
            'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
            'new_value': 'TextInput'
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name':
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
            'state_name': 'New state',
            'new_value': {
                'placeholder': {
                    'value': {
                        'content_id': (
                            content_id_generator.generate(
                                translation_domain.ContentType
                                .CUSTOMIZATION_ARG,
                                extra_prefix='placeholder')
                        ),
                        'unicode_str': ''
                    }
                },
                'rows': {'value': 1},
                'catchMisspellings': {'value': False}
            }
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name': exp_domain.STATE_PROPERTY_CONTENT,
            'state_name': 'New state',
            'old_value': state_domain.SubtitledHtml(
                'content_3', '').to_dict(),
            'new_value': state_domain.SubtitledHtml(
                'content_3',
                '<oppia-noninteractive-image filepath-with-value='
                '"&quot;abc.png&quot;" caption-with-value="&quot;&quot;" '
                'alt-with-value="&quot;Image&quot;">'
                '</oppia-noninteractive-image>').to_dict()
        }), exp_domain.ExplorationChange({
            'cmd': 'edit_exploration_property',
            'property_name': 'next_content_id_index',
            'new_value': content_id_generator.next_content_id_index

        })]
        with utils.open_file(
            os.path.join(feconf.TESTS_DATA_DIR, 'img.png'), 'rb',
            encoding=None
        ) as f:
            raw_image = f.read()
        fs = fs_services.GcsFileSystem(
            feconf.ENTITY_TYPE_EXPLORATION, self.EXP_0_ID)
        fs.commit('image/abc.png', raw_image)
        exp_services.update_exploration(
            self.owner_id, exploration.id, change_list, '')
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(exploration.version, 2)

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_RENAME_STATE,
            'old_state_name': 'New state',
            'new_state_name': 'Renamed state'
        })]
        exp_services.update_exploration(
            self.owner_id, exploration.id, change_list, '')
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(exploration.version, 3)

        # Download version 2.
        zip_file_output = exp_services.export_to_zip_file(
            self.EXP_0_ID, version=2)
        zf = zipfile.ZipFile(zip_file_output)
        # Read function returns bytes, so we need to decode them before
        # we compare.
        self.assertEqual(
            zf.open('A title.yaml').read().decode('utf-8'),
            self.SAMPLE_YAML_CONTENT)

        # Download version 3.
        zip_file_output = exp_services.export_to_zip_file(
            self.EXP_0_ID, version=3)
        zf = zipfile.ZipFile(zip_file_output)
        # Read function returns bytes, so we need to decode them before
        # we compare.
        self.assertEqual(
            zf.open('A title.yaml').read().decode('utf-8'),
            self.UPDATED_YAML_CONTENT)


class YAMLExportUnitTests(ExplorationServicesUnitTests):
    """Test export methods for explorations represented as a dict whose keys
    are state names and whose values are YAML strings representing the state's
    contents.
    """

    _SAMPLE_INIT_STATE_CONTENT: str = (
        """card_is_checkpoint: true
classifier_model_id: null
content:
  content_id: content_0
  html: ''
interaction:
  answer_groups: []
  confirmed_unclassified_answers: []
  customization_args:
    catchMisspellings:
      value: false
    placeholder:
      value:
        content_id: ca_placeholder_2
        unicode_str: ''
    rows:
      value: 1
  default_outcome:
    dest: %s
    dest_if_really_stuck: null
    feedback:
      content_id: default_outcome_1
      html: ''
    labelled_as_correct: false
    missing_prerequisite_skill_id: null
    param_changes: []
    refresher_exploration_id: null
  hints: []
  id: TextInput
  solution: null
linked_skill_id: null
param_changes: []
recorded_voiceovers:
  voiceovers_mapping:
    ca_placeholder_2: {}
    content_0: {}
    default_outcome_1: {}
solicit_answer_details: false
""") % (feconf.DEFAULT_INIT_STATE_NAME)

    SAMPLE_EXPORTED_DICT: Final = {
        feconf.DEFAULT_INIT_STATE_NAME: _SAMPLE_INIT_STATE_CONTENT,
        'New state': (
            """card_is_checkpoint: false
classifier_model_id: null
content:
  content_id: content_3
  html: ''
interaction:
  answer_groups: []
  confirmed_unclassified_answers: []
  customization_args:
    catchMisspellings:
      value: false
    placeholder:
      value:
        content_id: ca_placeholder_5
        unicode_str: ''
    rows:
      value: 1
  default_outcome:
    dest: New state
    dest_if_really_stuck: null
    feedback:
      content_id: default_outcome_4
      html: ''
    labelled_as_correct: false
    missing_prerequisite_skill_id: null
    param_changes: []
    refresher_exploration_id: null
  hints: []
  id: TextInput
  solution: null
linked_skill_id: null
param_changes: []
recorded_voiceovers:
  voiceovers_mapping:
    ca_placeholder_5: {}
    content_3: {}
    default_outcome_4: {}
solicit_answer_details: false
""")
    }

    UPDATED_SAMPLE_DICT: Final = {
        feconf.DEFAULT_INIT_STATE_NAME: _SAMPLE_INIT_STATE_CONTENT,
        'Renamed state': (
            """card_is_checkpoint: false
classifier_model_id: null
content:
  content_id: content_3
  html: ''
interaction:
  answer_groups: []
  confirmed_unclassified_answers: []
  customization_args:
    catchMisspellings:
      value: false
    placeholder:
      value:
        content_id: ca_placeholder_5
        unicode_str: ''
    rows:
      value: 1
  default_outcome:
    dest: Renamed state
    dest_if_really_stuck: null
    feedback:
      content_id: default_outcome_4
      html: ''
    labelled_as_correct: false
    missing_prerequisite_skill_id: null
    param_changes: []
    refresher_exploration_id: null
  hints: []
  id: TextInput
  solution: null
linked_skill_id: null
param_changes: []
recorded_voiceovers:
  voiceovers_mapping:
    ca_placeholder_5: {}
    content_3: {}
    default_outcome_4: {}
solicit_answer_details: false
""")
    }

    def test_export_to_dict(self) -> None:
        """Test the export_to_dict() method."""
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, objective='The objective')
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        init_state = exploration.states[exploration.init_state_name]
        # Ruling out the possibility of None for mypy type checking.
        assert init_state.interaction.default_outcome is not None
        default_outcome_dict = init_state.interaction.default_outcome.to_dict()
        default_outcome_dict['dest'] = exploration.init_state_name
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name': (
                        exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME),
                    'state_name': exploration.init_state_name,
                    'new_value': default_outcome_dict
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_ADD_STATE,
                    'state_name': 'New state',
                    'content_id_for_state_content': (
                        content_id_generator.generate(
                            translation_domain.ContentType.CONTENT)
                    ),
                    'content_id_for_default_outcome': (
                        content_id_generator.generate(
                            translation_domain.ContentType.DEFAULT_OUTCOME)
                    )
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
                    'state_name': 'New state',
                    'new_value': 'TextInput'
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name':
                        exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                    'state_name': 'New state',
                    'new_value': {
                        'placeholder': {
                            'value': {
                                'content_id': content_id_generator.generate((
                                        translation_domain
                                        .ContentType.CUSTOMIZATION_ARG),
                                    extra_prefix='placeholder'
                                    ),
                                'unicode_str': ''
                            }
                        },
                        'rows': {'value': 1},
                        'catchMisspellings': {'value': False}
                    }
                }),
                exp_domain.ExplorationChange({
                    'cmd': 'edit_exploration_property',
                    'property_name': 'next_content_id_index',
                    'new_value': content_id_generator.next_content_id_index

                })], 'Add state name')

        dict_output = exp_services.export_states_to_yaml(
            self.EXP_0_ID, width=50)

        self.assertEqual(dict_output, self.SAMPLE_EXPORTED_DICT)

    def test_export_by_versions(self) -> None:
        """Test export_to_dict() for different versions."""
        self.maxDiff = None
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        self.assertEqual(exploration.version, 1)

        init_state = exploration.states[exploration.init_state_name]
        # Ruling out the possibility of None for mypy type checking.
        assert init_state.interaction.default_outcome is not None
        default_outcome_dict = init_state.interaction.default_outcome.to_dict()
        default_outcome_dict['dest'] = exploration.init_state_name
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name': (
                exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME),
            'state_name': exploration.init_state_name,
            'new_value': default_outcome_dict
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_ADD_STATE,
            'state_name': 'New state',
            'content_id_for_state_content': (
                content_id_generator.generate(
                    translation_domain.ContentType.CONTENT)
            ),
            'content_id_for_default_outcome': (
                content_id_generator.generate(
                    translation_domain.ContentType.DEFAULT_OUTCOME)
            )
        }), exp_domain.ExplorationChange({
            'cmd': 'edit_exploration_property',
            'property_name': 'next_content_id_index',
            'new_value': content_id_generator.next_content_id_index

        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'state_name': 'New state',
            'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
            'new_value': 'TextInput'
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name':
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
            'state_name': 'New state',
            'new_value': {
                'placeholder': {
                    'value': {
                        'content_id': content_id_generator.generate((
                                        translation_domain
                                        .ContentType.CUSTOMIZATION_ARG),
                                    extra_prefix='placeholder'
                                    ),
                        'unicode_str': ''
                    }
                },
                'rows': {'value': 1},
                'catchMisspellings': {'value': False}
            }
        })]
        exploration.objective = 'The objective'
        with utils.open_file(
            os.path.join(feconf.TESTS_DATA_DIR, 'img.png'), 'rb',
            encoding=None) as f:
            raw_image = f.read()
        fs = fs_services.GcsFileSystem(
            feconf.ENTITY_TYPE_EXPLORATION, self.EXP_0_ID)
        fs.commit('abc.png', raw_image)
        exp_services.update_exploration(
            self.owner_id, exploration.id, change_list, '')
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(exploration.version, 2)

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_RENAME_STATE,
            'old_state_name': 'New state',
            'new_state_name': 'Renamed state'
        })]
        exp_services.update_exploration(
            self.owner_id, exploration.id, change_list, '')
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(exploration.version, 3)

        # Download version 2.
        dict_output = exp_services.export_states_to_yaml(
            self.EXP_0_ID, version=2, width=50)
        self.assertEqual(dict_output, self.SAMPLE_EXPORTED_DICT)

        # Download version 3.
        dict_output = exp_services.export_states_to_yaml(
            self.EXP_0_ID, version=3, width=50)
        self.assertEqual(dict_output, self.UPDATED_SAMPLE_DICT)


# Here new_value argument can accept values of type str, int, bool and other
# types too, so to make the argument generalized for every type of values we
# used Any type here.
def _get_change_list(
    state_name: str,
    property_name: str, new_value: change_domain.AcceptableChangeDictTypes
) -> List[exp_domain.ExplorationChange]:
    """Generates a change list for a single state change."""
    return [exp_domain.ExplorationChange({
        'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
        'state_name': state_name,
        'property_name': property_name,
        'new_value': new_value
    })]


class UpdateStateTests(ExplorationServicesUnitTests):
    """Test updating a single state."""

    def setUp(self) -> None:
        super().setUp()
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id)

        self.init_state_name = exploration.init_state_name

        self.param_changes = [{
            'customization_args': {
                'list_of_values': ['1', '2'], 'parse_with_jinja': False
            },
            'name': 'myParam',
            'generator_id': 'RandomSelector'
        }]
        # List of answer groups to add into an interaction.
        self.interaction_answer_groups: List[
            state_domain.AnswerGroupDict
        ] = [{
            'rule_specs': [{
                'rule_type': 'Equals',
                'inputs': {'x': 0},
            }],
            'outcome': {
                'dest': self.init_state_name,
                'dest_if_really_stuck': None,
                'feedback': {
                    'content_id': 'feedback_1',
                    'html': '<p>Try again</p>'
                },
                'labelled_as_correct': False,
                'param_changes': [],
                'refresher_exploration_id': None,
                'missing_prerequisite_skill_id': None
            },
            'training_data': [],
            'tagged_skill_misconception_id': None
        }]
        # Default outcome specification for an interaction.
        self.interaction_default_outcome: state_domain.OutcomeDict = {
            'dest': self.init_state_name,
            'dest_if_really_stuck': None,
            'feedback': {
                'content_id': 'default_outcome',
                'html': '<p><strong>Incorrect</strong></p>'
            },
            'labelled_as_correct': False,
            'param_changes': [],
            'refresher_exploration_id': None,
            'missing_prerequisite_skill_id': None
        }

    def test_add_state_cmd(self) -> None:
        """Test adding of states."""
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )

        self.assertNotIn('new state', exploration.states)

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'new state',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }), exp_domain.ExplorationChange({
                'cmd': 'edit_exploration_property',
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index
            })], 'Add state name')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertIn('new state', exploration.states)

    def test_are_changes_mergeable_send_email(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID,
            [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'State 1',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }), exp_domain.ExplorationChange({
                'cmd': 'edit_exploration_property',
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index
            })], 'Added state')
        change_list_same_state_name = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_ADD_STATE,
            'state_name': 'State 1',
            'content_id_for_state_content': (
                content_id_generator.generate(
                    translation_domain.ContentType.CONTENT)
            ),
            'content_id_for_default_outcome': (
                content_id_generator.generate(
                    translation_domain.ContentType.DEFAULT_OUTCOME)
            )
        })]
        updated_exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertFalse(exp_services.are_changes_mergeable(
            self.EXP_0_ID, updated_exploration.version - 1,
            change_list_same_state_name
        ))

    def test_rename_state_cmd(self) -> None:
        """Test updating of state name."""
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        self.assertIn(feconf.DEFAULT_INIT_STATE_NAME, exploration.states)

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'new state',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }), exp_domain.ExplorationChange({
                'cmd': 'edit_exploration_property',
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index
            })], 'Add state name')

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_RENAME_STATE,
                'old_state_name': feconf.DEFAULT_INIT_STATE_NAME,
                'new_state_name': 'state',
            })], 'Change state name')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertIn('state', exploration.states)
        self.assertNotIn(feconf.DEFAULT_INIT_STATE_NAME, exploration.states)

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_RENAME_STATE,
            'old_state_name': 'new state',
            'new_state_name': 'new state changed name',
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.EXP_0_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list, 'Change state name')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertIn('new state changed name', exploration.states)

    def test_rename_state_cmd_with_unicode(self) -> None:
        """Test updating of state name to one that uses unicode characters."""
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)

        self.assertNotIn(u'¡Hola! αβγ', exploration.states)
        self.assertIn(feconf.DEFAULT_INIT_STATE_NAME, exploration.states)

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_RENAME_STATE,
                'old_state_name': feconf.DEFAULT_INIT_STATE_NAME,
                'new_state_name': u'¡Hola! αβγ',
            })], 'Change state name')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertIn(u'¡Hola! αβγ', exploration.states)
        self.assertNotIn(feconf.DEFAULT_INIT_STATE_NAME, exploration.states)

    def test_delete_state_cmd(self) -> None:
        """Test deleting a state name."""
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'new state',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }), exp_domain.ExplorationChange({
                'cmd': 'edit_exploration_property',
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index
            })], 'Add state name')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)

        self.assertIn('new state', exploration.states)

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_DELETE_STATE,
                'state_name': 'new state',
            })], 'delete state')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertNotIn('new state', exploration.states)

    def test_delete_state_cmd_rejects_obsolete_translation_suggestions(
        self
    ) -> None:
        """Verify deleting a state name rejects corresponding suggestions."""
        # Add a new state with content.
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        content_id = content_id_generator.generate(
            translation_domain.ContentType.CONTENT)
        change_list = [
            # Add state.
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'new state',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }),
            # Add content.
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'property_name': exp_domain.STATE_PROPERTY_CONTENT,
                'state_name': 'new state',
                'new_value': {
                    'content_id': content_id,
                    'html': '<p>old content html</p>'
                }
            }),
            exp_domain.ExplorationChange({
                'cmd': 'edit_exploration_property',
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index
            })
        ]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list, 'Initial commit')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertIn('new state', exploration.states)

        # Create a translation suggestion for the state content.
        add_translation_change_dict = {
            'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION,
            'state_name': 'new state',
            'content_id': content_id,
            'language_code': 'hi',
            'content_html': '<p>old content html</p>',
            'translation_html': '<p>Translation for original content.</p>',
            'data_format': 'html'
        }
        suggestion = suggestion_services.create_suggestion(
            feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
            feconf.ENTITY_TYPE_EXPLORATION, self.EXP_0_ID, 1, self.owner_id,
            add_translation_change_dict, 'test description')

        # The new translation suggestion should be in review.
        in_review_suggestion = suggestion_services.get_suggestion_by_id(
            suggestion.suggestion_id)
        self.assertEqual(
            in_review_suggestion.status,
            suggestion_models.STATUS_IN_REVIEW)

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_DELETE_STATE,
                'state_name': 'new state',
            })], 'delete state')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertNotIn('new state', exploration.states)

        # The translation suggestion should be rejected after the corresponding
        # state is deleted.
        rejected_suggestion = suggestion_services.get_suggestion_by_id(
            suggestion.suggestion_id)
        self.assertEqual(
            rejected_suggestion.status,
            suggestion_models.STATUS_REJECTED)

    def test_update_param_changes(self) -> None:
        """Test updating of param_changes."""
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'param_specs',
            'new_value': {
                'myParam': {'obj_type': 'UnicodeString'}
            }
        })]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list, '')
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name, 'param_changes', self.param_changes), '')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        param_changes = exploration.init_state.param_changes[0].to_dict()
        self.assertEqual(param_changes['name'], 'myParam')
        self.assertEqual(param_changes['generator_id'], 'RandomSelector')
        self.assertEqual(
            param_changes['customization_args'],
            {'list_of_values': ['1', '2'], 'parse_with_jinja': False})

    def test_update_invalid_param_changes(self) -> None:
        """Check that updates cannot be made to non-existent parameters."""
        with self.assertRaisesRegex(
            utils.ValidationError,
            r'The parameter with name \'myParam\' .* does not exist .*'
        ):
            exp_services.update_exploration(
                self.owner_id,
                self.EXP_0_ID,
                _get_change_list(
                    self.init_state_name, 'param_changes', self.param_changes),
                ''
            )

    def test_update_reserved_param_changes(self) -> None:
        param_changes = [{
            'customization_args': {
                'list_of_values': ['1', '2'], 'parse_with_jinja': False
            },
            'name': 'all',
            'generator_id': 'RandomSelector'
        }]
        with self.assertRaisesRegex(
            utils.ValidationError,
            re.escape(
                'The parameter name \'all\' is reserved. Please choose '
                'a different name for the parameter being set in')):
            exp_services.update_exploration(
                self.owner_id,
                self.EXP_0_ID,
                _get_change_list(
                    self.init_state_name, 'param_changes', param_changes),
                ''
            )

    def test_update_invalid_generator(self) -> None:
        """Test for check that the generator_id in param_changes exists."""
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'param_specs',
            'new_value': {
                'myParam': {'obj_type': 'UnicodeString'}
            }
        })]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list, '')

        self.param_changes[0]['generator_id'] = 'fake'
        with self.assertRaisesRegex(
            utils.ValidationError, 'Invalid generator ID'
        ):
            exp_services.update_exploration(
                self.owner_id,
                self.EXP_0_ID,
                _get_change_list(
                    self.init_state_name, 'param_changes', self.param_changes),
                ''
            )

    def test_update_interaction_id(self) -> None:
        """Test updating of interaction_id."""
        exp_services.update_exploration(
            self.owner_id,
            self.EXP_0_ID,
            _get_change_list(
                self.init_state_name, exp_domain.STATE_PROPERTY_INTERACTION_ID,
                'MultipleChoiceInput') +
            _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                {
                    'choices': {
                        'value': [{
                            'content_id': 'ca_choices_0',
                            'html': '<p>Option A</p>'
                        }, {
                            'content_id': 'ca_choices_1',
                            'html': '<p>Option B</p>'
                        }]
                    },
                    'showChoicesInShuffledOrder': {'value': False}
                }),
            '')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.interaction.id, 'MultipleChoiceInput')

        # Check that the property can be changed when working
        # on old version.
        # Adding a content change just to increase the version.
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name, 'content', {
                    'html': '<p><strong>Test content</strong></p>',
                    'content_id': 'content_0',
                }),
            '')
        change_list = _get_change_list(
            self.init_state_name, exp_domain.STATE_PROPERTY_INTERACTION_ID,
            'Continue') + _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                {
                    'buttonText': {
                        'value': {
                            'content_id': 'ca_buttonText_1',
                            'unicode_str': 'Continue'
                        }
                    }
                })
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.EXP_0_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.owner_id,
            self.EXP_0_ID,
            change_list,
            '')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.interaction.id, 'Continue')

    def test_update_interaction_customization_args(self) -> None:
        """Test updating of interaction customization_args."""
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID,
            _get_change_list(
                self.init_state_name, exp_domain.STATE_PROPERTY_INTERACTION_ID,
                'MultipleChoiceInput') +
            _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                {
                    'choices': {
                        'value': [{
                            'content_id': 'ca_choices_0',
                            'html': '<p>Option A</p>'
                        }, {
                            'content_id': 'ca_choices_1',
                            'html': '<p>Option B</p>'
                        }]
                    },
                    'showChoicesInShuffledOrder': {'value': False}
                }),
            '')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        # Ruling out the possibility of any other type for mypy type checking.
        assert isinstance(
            exploration.init_state.interaction.customization_args[
            'choices'].value,
            list
        )
        # Here we use cast because we are narrowing down the type from
        # various customization args value types to List[SubtitledHtml]
        # type, and this is done because here we are accessing 'choices'
        # key from MultipleChoiceInput customization arg whose value is
        # always of List[SubtitledHtml] type.
        choices = cast(
            List[state_domain.SubtitledHtml],
            exploration.init_state.interaction.customization_args[
                'choices'
            ].value
        )
        self.assertEqual(choices[0].html, '<p>Option A</p>')
        self.assertEqual(choices[0].content_id, 'ca_choices_0')
        self.assertEqual(choices[1].html, '<p>Option B</p>')
        self.assertEqual(choices[1].content_id, 'ca_choices_1')

        # Check that the property can be changed when working
        # on old version.
        # Adding a content change just to increase the version.
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name, 'content', {
                    'html': '<p><strong>Test content</strong></p>',
                    'content_id': 'content_0',
                }),
            '')
        change_list = _get_change_list(
            self.init_state_name, exp_domain.STATE_PROPERTY_INTERACTION_ID,
            'Continue') + _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                {
                    'buttonText': {
                        'value': {
                            'content_id': 'ca_buttonText_1',
                            'unicode_str': 'Continue'
                        }
                    }
                })
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.EXP_0_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list, '')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        customization_args = (
            exploration.init_state.interaction.customization_args)
        # Here we use cast because we are narrowing down the type from various
        # customization args value types to 'SubtitledUnicode' type, and this
        # is done because here we are accessing 'buttontext' key from continue
        # customization arg whose value is always of SubtitledUnicode type.
        button_text_subtitle_unicode = cast(
            state_domain.SubtitledUnicode,
            customization_args['buttonText'].value
        )
        self.assertEqual(
            button_text_subtitle_unicode.unicode_str,
            'Continue')

    def test_update_interaction_customization_args_rejects_obsolete_translation_suggestions( # pylint: disable=line-too-long
        self
    ) -> None:
        # Add a Continue button interaction to the exploration.
        content_id = 'ca_buttonText_1'
        change_list = (
            _get_change_list(
                self.init_state_name, exp_domain.STATE_PROPERTY_INTERACTION_ID,
                'Continue') +
            _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                {
                    'buttonText': {
                        'value': {
                            'content_id': content_id,
                            'unicode_str': 'Continue'
                        }
                    }
                }))
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list, 'Initial commit')

        # Create a translation suggestion for the Continue button content.
        add_translation_change_dict = {
            'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION,
            'state_name': self.init_state_name,
            'content_id': content_id,
            'language_code': 'hi',
            'content_html': 'Continue',
            'translation_html': '<p>Translation for original content.</p>',
            'data_format': 'html'
        }
        suggestion = suggestion_services.create_suggestion(
            feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
            feconf.ENTITY_TYPE_EXPLORATION, self.EXP_0_ID, 1, self.owner_id,
            add_translation_change_dict, 'test description')
        # The new translation suggestion should be in review.
        in_review_suggestion = suggestion_services.get_suggestion_by_id(
            suggestion.suggestion_id)
        self.assertEqual(
            in_review_suggestion.status,
            suggestion_models.STATUS_IN_REVIEW)

        # Replace the Continue button content ID.
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                {
                    'buttonText': {
                        'value': {
                            'content_id': 'new_content_id',
                            'unicode_str': 'Continue'
                        }
                    }
                }),
            'Replace Continue button content ID')

        # The translation suggestion should be rejected after the corresponding
        # content ID is deleted.
        rejected_suggestion = suggestion_services.get_suggestion_by_id(
            suggestion.suggestion_id)
        self.assertEqual(
            rejected_suggestion.status,
            suggestion_models.STATUS_REJECTED)

    def test_update_interaction_handlers_fails(self) -> None:
        """Test legacy interaction handler updating."""
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID,
            [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'State 2',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }), exp_domain.ExplorationChange({
                'cmd': 'edit_exploration_property',
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index
            })] +
            _get_change_list(
                'State 2',
                exp_domain.STATE_PROPERTY_INTERACTION_ID,
                'TextInput') +
            _get_change_list(
                'State 2',
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                {
                    'placeholder': {
                        'value': {
                            'content_id': 'ca_placeholder_0',
                            'unicode_str': ''
                        }
                    },
                    'rows': {'value': 1},
                    'catchMisspellings': {'value': False}
                }),
            'Add state name')

        self.interaction_default_outcome['dest'] = 'State 2'
        with self.assertRaisesRegex(
            utils.InvalidInputException,
            'Editing interaction handlers is no longer supported'
            ):
            exp_services.update_exploration(
                self.owner_id, self.EXP_0_ID,
                _get_change_list(
                    self.init_state_name,
                    exp_domain.STATE_PROPERTY_INTERACTION_ID,
                    'MultipleChoiceInput') +
                _get_change_list(
                    self.init_state_name,
                    exp_domain.STATE_PROPERTY_INTERACTION_HANDLERS,
                    self.interaction_answer_groups),
                '')

    def test_update_interaction_answer_groups(self) -> None:
        """Test updating of interaction_answer_groups."""
        # We create a second state to use as a rule destination.
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID,
            [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'State 2',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }), exp_domain.ExplorationChange({
                'cmd': 'edit_exploration_property',
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index
            })] +
            _get_change_list(
                'State 2',
                exp_domain.STATE_PROPERTY_INTERACTION_ID,
                'TextInput') +
            _get_change_list(
                'State 2',
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                {
                    'placeholder': {
                        'value': {
                            'content_id': 'ca_placeholder_0',
                            'unicode_str': ''
                        }
                    },
                    'rows': {'value': 1},
                    'catchMisspellings': {'value': False}
                }),
            'Add state name')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.interaction_default_outcome['dest'] = 'State 2'
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID,
            _get_change_list(
                self.init_state_name, exp_domain.STATE_PROPERTY_INTERACTION_ID,
                'MultipleChoiceInput') +
            _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                {
                    'choices': {
                        'value': [{
                            'content_id': 'ca_choices_0',
                            'html': '<p>Option A</p>'
                        }, {
                            'content_id': 'ca_choices_1',
                            'html': '<p>Option B</p>'
                        }]
                    },
                    'showChoicesInShuffledOrder': {'value': False}
                }) +
            _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_INTERACTION_ANSWER_GROUPS,
                self.interaction_answer_groups) +
            _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME,
                self.interaction_default_outcome),
            '')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        init_state = exploration.init_state
        init_interaction = init_state.interaction
        rule_specs = init_interaction.answer_groups[0].rule_specs
        outcome = init_interaction.answer_groups[0].outcome
        self.assertEqual(rule_specs[0].rule_type, 'Equals')
        self.assertEqual(rule_specs[0].inputs, {'x': 0})
        self.assertEqual(outcome.feedback.html, '<p>Try again</p>')
        self.assertEqual(outcome.dest, self.init_state_name)
        # Ruling out the possibility of None for mypy type checking.
        assert init_interaction.default_outcome is not None
        self.assertEqual(init_interaction.default_outcome.dest, 'State 2')

        change_list = (
            _get_change_list(
                'State 2', exp_domain.STATE_PROPERTY_INTERACTION_ID,
                'MultipleChoiceInput') +
            _get_change_list(
                'State 2',
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                {
                    'choices': {
                        'value': [{
                            'content_id': 'ca_choices_1',
                            'html': '<p>Option A</p>'
                        }, {
                            'content_id': 'ca_choices_2',
                            'html': '<p>Option B</p>'
                        }]
                    },
                    'showChoicesInShuffledOrder': {'value': False}
                }) +
            _get_change_list(
                'State 2',
                exp_domain.STATE_PROPERTY_INTERACTION_ANSWER_GROUPS,
                [{
                    'rule_specs': [{
                        'rule_type': 'Equals',
                        'inputs': {'x': 0},
                    }],
                    'outcome': {
                        'dest': 'State 2',
                        'dest_if_really_stuck': None,
                        'feedback': {
                            'content_id': 'feedback_3',
                            'html': '<p>Try again</p>'
                        },
                        'labelled_as_correct': False,
                        'param_changes': [],
                        'refresher_exploration_id': None,
                        'missing_prerequisite_skill_id': None
                    },
                    'training_data': [],
                    'tagged_skill_misconception_id': None
                }]) +
            _get_change_list(
                'State 2',
                exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME,
                {
                    'dest': 'State 2',
                    'dest_if_really_stuck': None,
                    'feedback': {
                        'content_id': 'default_outcome',
                        'html': '<p><strong>Incorrect</strong></p>'
                    },
                    'labelled_as_correct': False,
                    'param_changes': [],
                    'refresher_exploration_id': None,
                    'missing_prerequisite_skill_id': None
                }))
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.EXP_0_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID,
            change_list,
            '')
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        second_state = exploration.states['State 2']
        second_state_interaction = second_state.interaction
        # Ruling out the possibility of None for mypy type checking.
        assert second_state_interaction.default_outcome is not None
        rule_specs = second_state_interaction.answer_groups[0].rule_specs
        outcome = second_state_interaction.answer_groups[0].outcome
        self.assertEqual(rule_specs[0].rule_type, 'Equals')
        self.assertEqual(rule_specs[0].inputs, {'x': 0})
        self.assertEqual(outcome.feedback.html, '<p>Try again</p>')
        self.assertEqual(outcome.dest, 'State 2')
        self.assertEqual(
            second_state_interaction.default_outcome.dest, 'State 2')

    def test_update_state_invalid_state(self) -> None:
        """Test that rule destination states cannot be non-existent."""
        self.interaction_answer_groups[0]['outcome']['dest'] = 'INVALID'
        with self.assertRaisesRegex(
            utils.ValidationError,
            'The destination INVALID is not a valid state'
            ):
            exp_services.update_exploration(
                self.owner_id, self.EXP_0_ID,
                _get_change_list(
                    self.init_state_name,
                    exp_domain.STATE_PROPERTY_INTERACTION_ID,
                    'MultipleChoiceInput') +
                _get_change_list(
                    self.init_state_name,
                    exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                    {
                        'choices': {
                            'value': [{
                                'content_id': 'ca_choices_0',
                                'html': '<p>Option A</p>'
                            }, {
                                'content_id': 'ca_choices_1',
                                'html': '<p>Option B</p>'
                            }]
                        },
                        'showChoicesInShuffledOrder': {'value': False}
                    }) +
                _get_change_list(
                    self.init_state_name,
                    exp_domain.STATE_PROPERTY_INTERACTION_ANSWER_GROUPS,
                    self.interaction_answer_groups) +
                _get_change_list(
                    self.init_state_name,
                    exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME,
                    self.interaction_default_outcome),
                '')

    def test_update_state_variable_types(self) -> None:
        """Test that parameters in rules must have the correct type."""
        self.interaction_answer_groups[0]['rule_specs'][0][
            'inputs']['x'] = 'abc'
        with self.assertRaisesRegex(
            Exception,
            'Value has the wrong type. It should be a NonnegativeInt. '
            'The value is abc'
        ):
            exp_services.update_exploration(
                self.owner_id, self.EXP_0_ID,
                _get_change_list(
                    self.init_state_name,
                    exp_domain.STATE_PROPERTY_INTERACTION_ID,
                    'MultipleChoiceInput') +
                _get_change_list(
                    self.init_state_name,
                    exp_domain.STATE_PROPERTY_INTERACTION_ANSWER_GROUPS,
                    self.interaction_answer_groups) +
                _get_change_list(
                    self.init_state_name,
                    exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME,
                    self.interaction_default_outcome),
                '')

    def test_update_content(self) -> None:
        """Test updating of content."""
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name, 'content', {
                    'html': '<p><strong>Test content</strong></p>',
                    'content_id': 'content_0',
                }),
            '')

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.content.html,
            '<p><strong>Test content</strong></p>')

    def test_update_solicit_answer_details(self) -> None:
        """Test updating of solicit_answer_details."""
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.solicit_answer_details, False)
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_SOLICIT_ANSWER_DETAILS,
                True),
            '')
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.solicit_answer_details, True)

        # Check that the property can be changed when working
        # on old version.
        # Adding a content change just to increase the version.
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name, 'content', {
                    'html': '<p><strong>Test content</strong></p>',
                    'content_id': 'content_0',
                }),
            '')

        change_list = _get_change_list(
            self.init_state_name,
            exp_domain.STATE_PROPERTY_SOLICIT_ANSWER_DETAILS,
            False)
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.EXP_0_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list,
            '')

        # Assert that exploration's final version consist of all the
        # changes.
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.content.html,
            '<p><strong>Test content</strong></p>')
        self.assertEqual(
            exploration.init_state.solicit_answer_details, False)

    def test_update_solicit_answer_details_with_non_bool_fails(self) -> None:
        """Test updating of solicit_answer_details with non bool value."""
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.solicit_answer_details, False)
        with self.assertRaisesRegex(
            Exception, (
                'Expected solicit_answer_details to be a bool, received ')):
            exp_services.update_exploration(
                self.owner_id, self.EXP_0_ID, _get_change_list(
                    self.init_state_name,
                    exp_domain.STATE_PROPERTY_SOLICIT_ANSWER_DETAILS,
                    'abc'),
                '')
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.solicit_answer_details, False)

        # Check that the property can be changed when working
        # on old version.
        # Adding a content change just to upgrade the version.
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name, 'content', {
                    'html': '<p><strong>Test content</strong></p>',
                    'content_id': 'content_0',
                }),
            '')
        change_list = _get_change_list(
            self.init_state_name,
            exp_domain.STATE_PROPERTY_SOLICIT_ANSWER_DETAILS,
            'abc')
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.EXP_0_ID, 1, change_list)
        self.assertTrue(changes_are_mergeable)
        with self.assertRaisesRegex(
            Exception, (
                'Expected solicit_answer_details to be a bool, received ')):
            exp_services.update_exploration(
                self.owner_id, self.EXP_0_ID, change_list, '')

        # Assert that exploration's final version consist of all the
        # changes.
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.content.html,
            '<p><strong>Test content</strong></p>')
        self.assertEqual(
            exploration.init_state.solicit_answer_details, False)

    def test_update_linked_skill_id(self) -> None:
        """Test updating linked_skill_id."""
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        self.assertEqual(
            exploration.init_state.linked_skill_id, None)
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'State1',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }), exp_domain.ExplorationChange({
                'cmd': 'edit_exploration_property',
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index
            })], 'Add state name')
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.states['State1'].linked_skill_id, None)
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                'State1',
                exp_domain.STATE_PROPERTY_LINKED_SKILL_ID,
                'string_1'),
            '')
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.states['State1'].linked_skill_id, 'string_1')

        # Check that the property can be changed when working
        # on old version.
        # Adding a content change just to upgrade the version.
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name, 'content', {
                    'html': '<p><strong>Test content</strong></p>',
                    'content_id': 'content_0',
                }),
            '')

        change_list = _get_change_list(
            'State1',
            exp_domain.STATE_PROPERTY_LINKED_SKILL_ID,
            'string_2')
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.EXP_0_ID, 3, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list, '')
        # Assert that exploration's final version consist of all the
        # changes.
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.content.html,
            '<p><strong>Test content</strong></p>')
        self.assertEqual(
            exploration.states['State1'].linked_skill_id, 'string_2')

    def test_update_card_is_checkpoint(self) -> None:
        """Test updating of card_is_checkpoint."""
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        self.assertEqual(
            exploration.init_state.card_is_checkpoint, True)
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'State1',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }), exp_domain.ExplorationChange({
                'cmd': 'edit_exploration_property',
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index
            })], 'Add state name')
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.states['State1'].card_is_checkpoint, False)

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                'State1',
                exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT,
                True),
            '')
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.states['State1'].card_is_checkpoint, True)

        # Check that the property can be changed when working
        # on old version.
        # Adding a content change just to upgrade the version.
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name, 'content', {
                    'html': '<p><strong>Test content</strong></p>',
                    'content_id': 'content_0',
                }),
            '')

        change_list = _get_change_list(
            'State1',
            exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT,
            False)
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.EXP_0_ID, 3, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list, '')
        # Assert that exploration's final version consist of all the
        # changes.
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.content.html,
            '<p><strong>Test content</strong></p>')
        self.assertEqual(
            exploration.states['State1'].card_is_checkpoint, False)

    def test_update_card_is_checkpoint_with_non_bool_fails(self) -> None:
        """Test updating of card_is_checkpoint with non bool value."""
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.card_is_checkpoint, True)
        with self.assertRaisesRegex(
            Exception, (
                'Expected card_is_checkpoint to be a bool, received ')):
            exp_services.update_exploration(
                self.owner_id, self.EXP_0_ID, _get_change_list(
                    self.init_state_name,
                    exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT,
                    'abc'),
                '')
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.card_is_checkpoint, True)

        # Adding a content change just to upgrade the version.
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name, 'content', {
                    'html': '<p><strong>Test content</strong></p>',
                    'content_id': 'content_0',
                }),
            '')

        change_list = _get_change_list(
            self.init_state_name,
            exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT,
            'abc')
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.EXP_0_ID, 1, change_list)
        self.assertTrue(changes_are_mergeable)
        with self.assertRaisesRegex(
            Exception, (
                'Expected card_is_checkpoint to be a bool, received ')):
            exp_services.update_exploration(
                self.owner_id, self.EXP_0_ID, _get_change_list(
                    self.init_state_name,
                    exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT,
                    'abc'),
                '')
        # Assert that exploration's final version consist of all the
        # changes.
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(
            exploration.init_state.content.html,
            '<p><strong>Test content</strong></p>')
        self.assertEqual(
            exploration.init_state.card_is_checkpoint, True)

    def test_update_content_missing_key(self) -> None:
        """Test that missing keys in content yield an error."""
        with self.assertRaisesRegex(KeyError, 'content_id'):
            exp_services.update_exploration(
                self.owner_id, self.EXP_0_ID, _get_change_list(
                    self.init_state_name, 'content', {
                        'html': '<b>Test content</b>',
                    }),
                '')

    def test_set_edits_allowed(self) -> None:
        """Test update edits allowed field in an exploration."""
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(exploration.edits_allowed, True)

        exp_services.set_exploration_edits_allowed(self.EXP_0_ID, False)

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(exploration.edits_allowed, False)

    def test_migrate_exp_to_latest_version_migrates_to_version(self) -> None:
        """Test migrate exploration state schema to the latest version."""
        latest_schema_version = str(feconf.CURRENT_STATE_SCHEMA_VERSION)
        migration_change_list = [
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_MIGRATE_STATES_SCHEMA_TO_LATEST_VERSION,
                'from_version': '0',
                'to_version': latest_schema_version
            })
        ]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, migration_change_list,
            'Ran Exploration Migration job.')
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(exploration.version, 2)
        self.assertEqual(
            str(exploration.states_schema_version),
            latest_schema_version)

    def test_migrate_exp_to_earlier_version_raises_exception(self) -> None:
        """Test migrate state schema to earlier version raises exception."""
        latest_schema_version = feconf.CURRENT_STATE_SCHEMA_VERSION
        not_latest_schema_version = str(latest_schema_version - 1)
        migration_change_list = [
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_MIGRATE_STATES_SCHEMA_TO_LATEST_VERSION,
                'from_version': '0',
                'to_version': not_latest_schema_version
            })
        ]
        exception_string = (
            'Expected to migrate to the latest state schema '
            'version %s, received %s' % (
                latest_schema_version, not_latest_schema_version)
        )
        with self.assertRaisesRegex(Exception, exception_string):
            exp_services.update_exploration(
                self.owner_id, self.EXP_0_ID, migration_change_list,
                'Ran Exploration Migration job.')


class CommitMessageHandlingTests(ExplorationServicesUnitTests):
    """Test the handling of commit messages."""

    def setUp(self) -> None:
        super().setUp()
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, end_state_name='End')
        self.init_state_name = exploration.init_state_name

    def test_record_commit_message(self) -> None:
        """Check published explorations record commit messages."""
        rights_manager.publish_exploration(self.owner, self.EXP_0_ID)

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_INTERACTION_STICKY,
                False), 'A message')

        self.assertEqual(
            exp_services.get_exploration_snapshots_metadata(
                self.EXP_0_ID)[1]['commit_message'],
            'A message')

    def test_demand_commit_message(self) -> None:
        """Check published explorations demand commit messages."""
        rights_manager.publish_exploration(self.owner, self.EXP_0_ID)

        with self.assertRaisesRegex(
            ValueError,
            'Exploration is public so expected a commit message but received '
            'none.'
            ):
            exp_services.update_exploration(
                self.owner_id, self.EXP_0_ID, _get_change_list(
                    self.init_state_name,
                    exp_domain.STATE_PROPERTY_INTERACTION_STICKY, False), '')

    def test_unpublished_explorations_can_accept_commit_message(self) -> None:
        """Test unpublished explorations can accept optional commit messages."""
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_INTERACTION_STICKY, False
            ), 'A message')

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_INTERACTION_STICKY, True
            ), '')

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, _get_change_list(
                self.init_state_name,
                exp_domain.STATE_PROPERTY_INTERACTION_STICKY, True
            ), None)


class ExplorationSnapshotUnitTests(ExplorationServicesUnitTests):
    """Test methods relating to exploration snapshots."""

    SECOND_USERNAME: Final = 'abc123'
    SECOND_EMAIL: Final = 'abc123@gmail.com'

    def test_get_last_updated_by_human_ms(self) -> None:
        original_timestamp = utils.get_current_time_in_millisecs()

        self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, end_state_name='End')

        timestamp_after_first_edit = utils.get_current_time_in_millisecs()

        exp_services.update_exploration(
            feconf.MIGRATION_BOT_USER_ID, self.EXP_0_ID, [
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                    'property_name': 'title',
                    'new_value': 'New title'
                })], 'Did migration.')

        self.assertLess(
            original_timestamp,
            exp_services.get_last_updated_by_human_ms(self.EXP_0_ID))
        self.assertLess(
            exp_services.get_last_updated_by_human_ms(self.EXP_0_ID),
            timestamp_after_first_edit)

    def test_get_exploration_snapshots_metadata(self) -> None:
        self.signup(self.SECOND_EMAIL, self.SECOND_USERNAME)
        second_committer_id = self.get_user_id_from_email(self.SECOND_EMAIL)

        v1_exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id, end_state_name='End')
        snapshots_metadata = exp_services.get_exploration_snapshots_metadata(
            self.EXP_0_ID)
        self.assertEqual(len(snapshots_metadata), 1)
        self.assertDictContainsSubset({
            'commit_cmds': [{
                'cmd': 'create_new',
                'title': 'A title',
                'category': 'Algebra',
            }],
            'committer_id': self.owner_id,
            'commit_message': (
                'New exploration created with title \'A title\'.'),
            'commit_type': 'create',
            'version_number': 1
        }, snapshots_metadata[0])
        self.assertIn('created_on_ms', snapshots_metadata[0])

        # Publish the exploration. This does not affect the exploration version
        # history.
        rights_manager.publish_exploration(self.owner, self.EXP_0_ID)

        snapshots_metadata = exp_services.get_exploration_snapshots_metadata(
            self.EXP_0_ID)
        self.assertEqual(len(snapshots_metadata), 1)
        self.assertDictContainsSubset({
            'commit_cmds': [{
                'cmd': 'create_new',
                'title': 'A title',
                'category': 'Algebra'
            }],
            'committer_id': self.owner_id,
            'commit_message': (
                'New exploration created with title \'A title\'.'),
            'commit_type': 'create',
            'version_number': 1
        }, snapshots_metadata[0])
        self.assertIn('created_on_ms', snapshots_metadata[0])

        # Modify the exploration. This affects the exploration version history.
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'title',
            'new_value': 'First title'
        })]
        change_list_dict = [change.to_dict() for change in change_list]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list, 'Changed title.')

        snapshots_metadata = exp_services.get_exploration_snapshots_metadata(
            self.EXP_0_ID)
        self.assertEqual(len(snapshots_metadata), 2)
        self.assertIn('created_on_ms', snapshots_metadata[0])
        self.assertDictContainsSubset({
            'commit_cmds': [{
                'cmd': 'create_new',
                'title': 'A title',
                'category': 'Algebra'
            }],
            'committer_id': self.owner_id,
            'commit_message': (
                'New exploration created with title \'A title\'.'),
            'commit_type': 'create',
            'version_number': 1
        }, snapshots_metadata[0])
        self.assertDictContainsSubset({
            'commit_cmds': change_list_dict,
            'committer_id': self.owner_id,
            'commit_message': 'Changed title.',
            'commit_type': 'edit',
            'version_number': 2,
        }, snapshots_metadata[1])
        self.assertLess(
            snapshots_metadata[0]['created_on_ms'],
            snapshots_metadata[1]['created_on_ms'])

        # Using the old version of the exploration should raise an error.
        change_list_swap = self.swap_to_always_return(
            exp_services, 'apply_change_list', value=v1_exploration)
        with change_list_swap, self.assertRaisesRegex(
            Exception, 'version 1, which is too old'):
            exp_services.update_exploration(
                second_committer_id, self.EXP_0_ID, None, 'commit_message')

        # Another person modifies the exploration.
        new_change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'title',
            'new_value': 'New title'
        })]
        new_change_list_dict = [change.to_dict() for change in new_change_list]

        exp_services.update_exploration(
            second_committer_id, self.EXP_0_ID, new_change_list,
            'Second commit.')

        snapshots_metadata = exp_services.get_exploration_snapshots_metadata(
            self.EXP_0_ID)
        self.assertEqual(len(snapshots_metadata), 3)
        self.assertDictContainsSubset({
            'commit_cmds': [{
                'cmd': 'create_new',
                'title': 'A title',
                'category': 'Algebra'
            }],
            'committer_id': self.owner_id,
            'commit_message': (
                'New exploration created with title \'A title\'.'),
            'commit_type': 'create',
            'version_number': 1
        }, snapshots_metadata[0])
        self.assertDictContainsSubset({
            'commit_cmds': change_list_dict,
            'committer_id': self.owner_id,
            'commit_message': 'Changed title.',
            'commit_type': 'edit',
            'version_number': 2,
        }, snapshots_metadata[1])
        self.assertDictContainsSubset({
            'commit_cmds': new_change_list_dict,
            'committer_id': second_committer_id,
            'commit_message': 'Second commit.',
            'commit_type': 'edit',
            'version_number': 3,
        }, snapshots_metadata[2])
        self.assertLess(
            snapshots_metadata[1]['created_on_ms'],
            snapshots_metadata[2]['created_on_ms'])

    def test_versioning_with_add_and_delete_states(self) -> None:

        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id)
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'title',
            'new_value': 'First title'
        })]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list, 'Changed title.')
        commit_dict_2 = {
            'committer_id': self.owner_id,
            'commit_message': 'Changed title.',
            'version_number': 2,
        }
        snapshots_metadata = exp_services.get_exploration_snapshots_metadata(
            self.EXP_0_ID)
        self.assertEqual(len(snapshots_metadata), 2)

        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_ADD_STATE,
            'state_name': 'New state',
            'content_id_for_state_content': (
                content_id_generator.generate(
                    translation_domain.ContentType.CONTENT)
            ),
            'content_id_for_default_outcome': (
                content_id_generator.generate(
                    translation_domain.ContentType.DEFAULT_OUTCOME)
            )
        }), exp_domain.ExplorationChange({
            'cmd': 'edit_exploration_property',
            'property_name': 'next_content_id_index',
            'new_value': content_id_generator.next_content_id_index
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'state_name': 'New state',
            'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
            'new_value': 'TextInput'
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name':
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
            'state_name': 'New state',
            'new_value': {
                'placeholder': {
                    'value': {
                        'content_id': 'ca_placeholder_0',
                        'unicode_str': ''
                    }
                },
                'rows': {'value': 1},
                'catchMisspellings': {'value': False}
            }
        })]
        exp_services.update_exploration(
            'second_committer_id', exploration.id, change_list,
            'Added new state')

        commit_dict_3 = {
            'committer_id': 'second_committer_id',
            'commit_message': 'Added new state',
            'version_number': 3,
        }
        snapshots_metadata = exp_services.get_exploration_snapshots_metadata(
            self.EXP_0_ID)
        self.assertEqual(len(snapshots_metadata), 3)
        self.assertDictContainsSubset(
            commit_dict_3, snapshots_metadata[2])
        self.assertDictContainsSubset(commit_dict_2, snapshots_metadata[1])
        for ind in range(len(snapshots_metadata) - 1):
            self.assertLess(
                snapshots_metadata[ind]['created_on_ms'],
                snapshots_metadata[ind + 1]['created_on_ms'])

        # Perform an invalid action: delete a state that does not exist. This
        # should not create a new version.
        with self.assertRaisesRegex(ValueError, 'does not exist'):
            exploration.delete_state('invalid_state_name')

        # Now delete the new state.
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_DELETE_STATE,
            'state_name': 'New state'
        })]
        exp_services.update_exploration(
            'committer_id_3', exploration.id, change_list,
            'Deleted state: New state')

        commit_dict_4 = {
            'committer_id': 'committer_id_3',
            'commit_message': 'Deleted state: New state',
            'version_number': 4,
        }
        snapshots_metadata = exp_services.get_exploration_snapshots_metadata(
            self.EXP_0_ID)
        self.assertEqual(len(snapshots_metadata), 4)
        self.assertDictContainsSubset(commit_dict_4, snapshots_metadata[3])
        self.assertDictContainsSubset(commit_dict_3, snapshots_metadata[2])
        self.assertDictContainsSubset(commit_dict_2, snapshots_metadata[1])
        for ind in range(len(snapshots_metadata) - 1):
            self.assertLess(
                snapshots_metadata[ind]['created_on_ms'],
                snapshots_metadata[ind + 1]['created_on_ms'])

        # The final exploration should have exactly one state.
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(len(exploration.states), 1)

    def test_versioning_with_reverting(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id)

        # In version 1, the title was 'A title'.
        # In version 2, the title becomes 'V2 title'.
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'title',
            'new_value': 'V2 title'
        })]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list, 'Changed title.')

        # In version 3, a new state is added.
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_ADD_STATE,
            'state_name': 'New state',
            'content_id_for_state_content': (
                content_id_generator.generate(
                    translation_domain.ContentType.CONTENT)
            ),
            'content_id_for_default_outcome': (
                content_id_generator.generate(
                    translation_domain.ContentType.DEFAULT_OUTCOME)
            )
        }), exp_domain.ExplorationChange({
            'cmd': 'edit_exploration_property',
            'property_name': 'next_content_id_index',
            'new_value': content_id_generator.next_content_id_index
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'state_name': 'New state',
            'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
            'new_value': 'TextInput'
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name':
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
            'state_name': 'New state',
            'new_value': {
                'placeholder': {
                    'value': {
                        'content_id': 'ca_placeholder_0',
                        'unicode_str': ''
                    }
                },
                'rows': {'value': 1},
                'catchMisspellings': {'value': False}
            }
        })]
        exp_services.update_exploration(
            'committer_id_v3', exploration.id, change_list, 'Added new state')

        # It is not possible to revert from anything other than the most
        # current version.
        with self.assertRaisesRegex(Exception, 'too old'):
            exp_services.revert_exploration(
                'committer_id_v4', self.EXP_0_ID, 2, 1)

        # Version 4 is a reversion to version 1.
        exp_services.revert_exploration('committer_id_v4', self.EXP_0_ID, 3, 1)
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        self.assertEqual(exploration.title, 'A title')
        self.assertEqual(len(exploration.states), 1)
        self.assertEqual(exploration.version, 4)

        snapshots_metadata = exp_services.get_exploration_snapshots_metadata(
            self.EXP_0_ID)

        commit_dict_4 = {
            'committer_id': 'committer_id_v4',
            'commit_message': 'Reverted exploration to version 1',
            'version_number': 4,
        }
        commit_dict_3 = {
            'committer_id': 'committer_id_v3',
            'commit_message': 'Added new state',
            'version_number': 3,
        }
        self.assertEqual(len(snapshots_metadata), 4)
        self.assertDictContainsSubset(commit_dict_3, snapshots_metadata[2])
        self.assertDictContainsSubset(commit_dict_4, snapshots_metadata[3])
        self.assertLess(
            snapshots_metadata[2]['created_on_ms'],
            snapshots_metadata[3]['created_on_ms'])

    def test_get_composite_change_list(self) -> None:
        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID, self.owner_id)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )

        # Upgrade to version 2.
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'old_value': 'A title',
                'new_value': 'new title'
            })], 'Changed title.')

        # Change list for version 3.
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_ADD_STATE,
            'state_name': 'New state',
            'content_id_for_state_content': (
                content_id_generator.generate(
                    translation_domain.ContentType.CONTENT)
            ),
            'content_id_for_default_outcome': (
                content_id_generator.generate(
                    translation_domain.ContentType.DEFAULT_OUTCOME))
        }), exp_domain.ExplorationChange({
            'cmd': 'edit_exploration_property',
            'property_name': 'next_content_id_index',
            'new_value': content_id_generator.next_content_id_index
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'state_name': 'New state',
            'old_value': None,
            'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
            'new_value': 'TextInput'
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name':
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
            'state_name': 'New state',
            'old_value': None,
            'new_value': {
                'placeholder': {
                    'value': {
                        'content_id': 'ca_placeholder_0',
                        'unicode_str': ''
                    }
                },
                'rows': {'value': 1},
                'catchMisspellings': {'value': False}
            }
        })]
        exp_services.update_exploration(
            'second_committer_id', exploration.id, change_list,
            'Added new state and interaction')

        # Change list for version 4.
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_DELETE_STATE,
            'state_name': 'New state'
        })]
        exp_services.update_exploration(
            'committer_id_3', exploration.id, change_list,
            'Deleted state: New state')

        # Complete change list from version 1 to 4.
        composite_change_list_dict_expected = [{
            'cmd': exp_domain.CMD_ADD_STATE,
            'state_name': 'New state',
            'content_id_for_state_content': 'content_3',
            'content_id_for_default_outcome': 'default_outcome_4'
        }, {
            'cmd': 'edit_exploration_property',
            'property_name': 'next_content_id_index',
            'new_value': 5,
            'old_value': None
        }, {
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'old_value': None,
            'state_name': 'New state',
            'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
            'new_value': 'TextInput'
        }, {
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name':
                exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
            'state_name': 'New state',
            'old_value': None,
            'new_value': {
                'placeholder': {
                    'value': {
                        'content_id': 'ca_placeholder_0',
                        'unicode_str': ''
                    }
                },
                'rows': {'value': 1},
                'catchMisspellings': {'value': False}
            }
        }, {
            'cmd': exp_domain.CMD_DELETE_STATE,
            'state_name': 'New state'
        }]

        with self.assertRaisesRegex(
            Exception,
            'Unexpected error: Trying to find change list from version %s '
            'of exploration to version %s.'
            % (4, 1)):
            exp_services.get_composite_change_list(
                self.EXP_0_ID, 4, 1)

        composite_change_list = exp_services.get_composite_change_list(
            self.EXP_0_ID, 2, 4)
        composite_change_list_dict = [change.to_dict()
                                      for change in composite_change_list]
        self.assertEqual(
            composite_change_list_dict_expected, composite_change_list_dict)

    def test_reverts_exp_to_safe_state_when_content_model_is_missing(
        self
    ) -> None:
        self.save_new_valid_exploration('0', self.owner_id)
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 1')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 2')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 3')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 4')

        version = exp_services.rollback_exploration_to_safe_state('0')
        self.assertEqual(version, 5)

        snapshot_content_model = (
            exp_models.ExplorationSnapshotContentModel.get(
                '0-5', strict=True))
        snapshot_content_model.delete()

        version = exp_services.rollback_exploration_to_safe_state('0')
        self.assertEqual(version, 4)

    def test_reverts_exp_to_safe_state_when_several_models_are_missing(
        self
    ) -> None:
        self.save_new_valid_exploration('0', self.owner_id)
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 1')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 2')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 3')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 4')

        version = exp_services.rollback_exploration_to_safe_state('0')
        self.assertEqual(version, 5)

        snapshot_content_model = (
            exp_models.ExplorationSnapshotContentModel.get(
                '0-5', strict=True))
        snapshot_content_model.delete()
        snapshot_metadata_model = (
            exp_models.ExplorationSnapshotMetadataModel.get(
                '0-4', strict=True))
        snapshot_metadata_model.delete()

        version = exp_services.rollback_exploration_to_safe_state('0')
        self.assertEqual(version, 3)

    def test_reverts_exp_to_safe_state_when_metadata_model_is_missing(
        self
    ) -> None:
        self.save_new_valid_exploration('0', self.owner_id)
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 1')
        exp_services.update_exploration(
                self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 2')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 3')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 4')

        version = exp_services.rollback_exploration_to_safe_state('0')
        self.assertEqual(version, 5)

        snapshot_metadata_model = (
            exp_models.ExplorationSnapshotMetadataModel.get(
                '0-5', strict=True))
        snapshot_metadata_model.delete()

        version = exp_services.rollback_exploration_to_safe_state('0')
        self.assertEqual(version, 4)

    def test_reverts_exp_to_safe_state_when_both_models_are_missing(
        self
    ) -> None:
        self.save_new_valid_exploration('0', self.owner_id)
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 1')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 2')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 3')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 4')

        version = exp_services.rollback_exploration_to_safe_state('0')
        self.assertEqual(version, 5)

        snapshot_content_model = (
            exp_models.ExplorationSnapshotContentModel.get(
                '0-5', strict=True))
        snapshot_content_model.delete()

        snapshot_metadata_model = (
            exp_models.ExplorationSnapshotMetadataModel.get(
                '0-5', strict=True))
        snapshot_metadata_model.delete()

        version = exp_services.rollback_exploration_to_safe_state('0')
        self.assertEqual(version, 4)

    def test_does_not_revert_exp_when_no_models_are_missing(self) -> None:
        self.save_new_valid_exploration('0', self.owner_id)
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 1')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 2')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 3')
        exp_services.update_exploration(
            self.owner_id, '0', [exp_domain.ExplorationChange({
                'new_value': {
                    'content_id': 'content_0',
                    'html': 'content 1'
                },
                'state_name': 'Introduction',
                'old_value': {
                    'content_id': 'content_0',
                    'html': ''
                },
                'cmd': 'edit_state_property',
                'property_name': 'content'
            })], 'Update 4')

        version = exp_services.rollback_exploration_to_safe_state('0')

        self.assertEqual(version, 5)


class ExplorationCommitLogUnitTests(ExplorationServicesUnitTests):
    """Test methods relating to the exploration commit log."""

    ALBERT_EMAIL: Final = 'albert@example.com'
    BOB_EMAIL: Final = 'bob@example.com'
    ALBERT_NAME: Final = 'albert'
    BOB_NAME: Final = 'bob'

    EXP_ID_1: Final = 'eid1'
    EXP_ID_2: Final = 'eid2'

    COMMIT_ALBERT_CREATE_EXP_1: Final = {
        'version': 1,
        'exploration_id': EXP_ID_1,
        'commit_type': 'create',
        'post_commit_community_owned': False,
        'post_commit_is_private': True,
        'commit_message': 'New exploration created with title \'A title\'.',
        'post_commit_status': 'private'
    }

    COMMIT_BOB_EDIT_EXP_1: Final = {
        'version': 2,
        'exploration_id': EXP_ID_1,
        'commit_type': 'edit',
        'post_commit_community_owned': False,
        'post_commit_is_private': True,
        'commit_message': 'Changed title.',
        'post_commit_status': 'private'
    }

    COMMIT_ALBERT_CREATE_EXP_2: Final = {
        'version': 1,
        'exploration_id': 'eid2',
        'commit_type': 'create',
        'post_commit_community_owned': False,
        'post_commit_is_private': True,
        'commit_message': 'New exploration created with title \'A title\'.',
        'post_commit_status': 'private'
    }

    COMMIT_ALBERT_EDIT_EXP_1: Final = {
        'version': 3,
        'exploration_id': 'eid1',
        'commit_type': 'edit',
        'post_commit_community_owned': False,
        'post_commit_is_private': True,
        'commit_message': 'Changed title to Albert1 title.',
        'post_commit_status': 'private'
    }

    COMMIT_ALBERT_EDIT_EXP_2: Final = {
        'version': 2,
        'exploration_id': 'eid2',
        'commit_type': 'edit',
        'post_commit_community_owned': False,
        'post_commit_is_private': True,
        'commit_message': 'Changed title to Albert2.',
        'post_commit_status': 'private'
    }

    COMMIT_BOB_REVERT_EXP_1: Final = {
        'username': 'bob',
        'version': 4,
        'exploration_id': 'eid1',
        'commit_type': 'revert',
        'post_commit_community_owned': False,
        'post_commit_is_private': True,
        'commit_message': 'Reverted exploration to version 2',
        'post_commit_status': 'private'
    }

    COMMIT_ALBERT_DELETE_EXP_1: Final = {
        'version': 5,
        'exploration_id': 'eid1',
        'commit_type': 'delete',
        'post_commit_community_owned': False,
        'post_commit_is_private': True,
        'commit_message': feconf.COMMIT_MESSAGE_EXPLORATION_DELETED,
        'post_commit_status': 'private'
    }

    COMMIT_ALBERT_PUBLISH_EXP_2: Final = {
        'version': None,
        'exploration_id': 'eid2',
        'commit_type': 'edit',
        'post_commit_community_owned': False,
        'post_commit_is_private': False,
        'commit_message': 'exploration published.',
        'post_commit_status': 'public'
    }

    def setUp(self) -> None:
        """Populate the database of explorations to be queried against.

        The sequence of events is:
        - (1) Albert creates EXP_ID_1.
        - (2) Bob edits the title of EXP_ID_1.
        - (3) Albert creates EXP_ID_2.
        - (4) Albert edits the title of EXP_ID_1.
        - (5) Albert edits the title of EXP_ID_2.
        - (6) Bob reverts Albert's last edit to EXP_ID_1.
        - (7) Albert deletes EXP_ID_1.
        - Bob tries to publish EXP_ID_2, and is denied access.
        - (8) Albert publishes EXP_ID_2.
        """
        super().setUp()

        self.signup(self.ALBERT_EMAIL, self.ALBERT_NAME)
        self.signup(self.BOB_EMAIL, self.BOB_NAME)
        self.albert_id = self.get_user_id_from_email(self.ALBERT_EMAIL)
        self.bob_id = self.get_user_id_from_email(self.BOB_EMAIL)
        self.albert = user_services.get_user_actions_info(self.albert_id)
        self.bob = user_services.get_user_actions_info(self.bob_id)

        def populate_datastore() -> None:
            """Populates the database according to the sequence."""
            self.save_new_valid_exploration(
                self.EXP_ID_1, self.albert_id)

            change_list = [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'Exploration 1 title'
            })]
            exp_services.update_exploration(
                self.bob_id, self.EXP_ID_1, change_list, 'Changed title.')

            self.save_new_valid_exploration(
                self.EXP_ID_2, self.albert_id)

            change_list = [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'Exploration 1 Albert title'
            })]
            exp_services.update_exploration(
                self.albert_id, self.EXP_ID_1,
                change_list, 'Changed title to Albert1 title.')

            change_list = [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'Exploration 2 Albert title'
            })]
            exp_services.update_exploration(
                self.albert_id, self.EXP_ID_2,
                change_list, 'Changed title to Albert2.')

            exp_services.revert_exploration(self.bob_id, self.EXP_ID_1, 3, 2)

            exp_services.delete_exploration(self.albert_id, self.EXP_ID_1)

            # This commit should not be recorded.
            with self.assertRaisesRegex(
                Exception, 'This exploration cannot be published'
                ):
                rights_manager.publish_exploration(self.bob, self.EXP_ID_2)

            rights_manager.publish_exploration(self.albert, self.EXP_ID_2)

        populate_datastore()

    # TODO(#13059): Here we use MyPy ignore because after we fully type the
    # codebase we plan to get rid of the tests that intentionally test wrong
    # inputs that we can normally catch by typing.
    def test_get_next_page_of_all_non_private_commits_with_invalid_max_age(
        self
    ) -> None:
        with self.assertRaisesRegex(
            Exception,
            'max_age must be a datetime.timedelta instance. or None.'):
            exp_services.get_next_page_of_all_non_private_commits(
                max_age='invalid_max_age')  # type: ignore[arg-type]

    def test_get_next_page_of_all_non_private_commits(self) -> None:
        all_commits = (
            exp_services.get_next_page_of_all_non_private_commits()[0])
        self.assertEqual(len(all_commits), 1)
        commit_dicts = [commit.to_dict() for commit in all_commits]
        self.assertDictContainsSubset(
            self.COMMIT_ALBERT_PUBLISH_EXP_2, commit_dicts[0])

        # TODO(frederikcreemers@gmail.com): Test max_age here.

    def test_raises_error_if_solution_is_provided_without_interaction_id(
        self
    ) -> None:
        exploration = exp_domain.Exploration.create_default_exploration(
            'test_id', 'title', 'Home')
        exp_services.save_new_exploration('Test_user', exploration)

        state_solution_dict: state_domain.SolutionDict = {
            'answer_is_exclusive': True,
            'correct_answer': [
                '<p>state customization arg html 1</p>',
                '<p>state customization arg html 2</p>',
                '<p>state customization arg html 3</p>',
                '<p>state customization arg html 4</p>'
            ],
            'explanation': {
                'content_id': 'solution',
                'html': '<p>This is solution for state1</p>'
            }
        }
        change_list = exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'state_name': 'Home',
            'property_name': exp_domain.STATE_PROPERTY_INTERACTION_SOLUTION,
            'new_value': state_solution_dict,
        })
        with self.assertRaisesRegex(
            Exception,
            'solution cannot exist with None interaction id.'
        ):
            exp_services.apply_change_list('test_id', [change_list])


class ExplorationSearchTests(ExplorationServicesUnitTests):
    """Test exploration search."""

    USER_ID_1: Final = 'user_1'
    USER_ID_2: Final = 'user_2'

    def test_index_explorations_given_ids(self) -> None:
        all_exp_ids = ['id0', 'id1', 'id2', 'id3', 'id4']
        expected_exp_ids = all_exp_ids[:-1]
        all_exp_titles = [
            'title 0', 'title 1', 'title 2', 'title 3', 'title 4']
        expected_exp_titles = all_exp_titles[:-1]
        all_exp_categories = ['cat0', 'cat1', 'cat2', 'cat3', 'cat4']
        expected_exp_categories = all_exp_categories[:-1]

        def mock_add_documents_to_index(
            docs: List[Dict[str, str]], index: str
        ) -> List[str]:
            self.assertEqual(index, exp_services.SEARCH_INDEX_EXPLORATIONS)
            ids = [doc['id'] for doc in docs]
            titles = [doc['title'] for doc in docs]
            categories = [doc['category'] for doc in docs]
            self.assertEqual(set(ids), set(expected_exp_ids))
            self.assertEqual(set(titles), set(expected_exp_titles))
            self.assertEqual(set(categories), set(expected_exp_categories))
            return ids

        add_docs_counter = test_utils.CallCounter(mock_add_documents_to_index)
        add_docs_swap = self.swap(
            search_services,
            'add_documents_to_index',
            add_docs_counter)

        for i in range(5):
            self.save_new_valid_exploration(
                all_exp_ids[i],
                self.owner_id,
                title=all_exp_titles[i],
                category=all_exp_categories[i])

        # We're only publishing the first 4 explorations, so we're not
        # expecting the last exploration to be indexed.
        for i in range(4):
            rights_manager.publish_exploration(
                self.owner, expected_exp_ids[i])

        with add_docs_swap:
            exp_services.index_explorations_given_ids(all_exp_ids)

        self.assertEqual(add_docs_counter.times_called, 1)

    def test_updated_exploration_is_added_correctly_to_index(self) -> None:
        exp_id = 'id0'
        exp_title = 'title 0'
        exp_category = 'cat0'
        actual_docs = []
        initial_exp_doc = {
            'category': 'cat0',
            'id': 'id0',
            'language_code': 'en',
            'objective': 'An objective',
            'rank': 20,
            'tags': [],
            'title': 'title 0'}
        updated_exp_doc = {
            'category': 'cat1',
            'id': 'id0',
            'language_code': 'en',
            'objective': 'An objective',
            'rank': 20,
            'tags': [],
            'title': 'title 0'
        }

        def mock_add_documents_to_index(
            docs: List[Dict[str, str]], index: str
        ) -> None:
            self.assertEqual(index, exp_services.SEARCH_INDEX_EXPLORATIONS)
            actual_docs.extend(docs)

        add_docs_counter = test_utils.CallCounter(mock_add_documents_to_index)
        add_docs_swap = self.swap(
            search_services,
            'add_documents_to_index',
            add_docs_counter)

        with add_docs_swap:
            self.save_new_valid_exploration(
                exp_id, self.owner_id, title=exp_title, category=exp_category,
                end_state_name='End')

            rights_manager.publish_exploration(self.owner, exp_id)
            self.assertEqual(actual_docs, [initial_exp_doc])
            self.assertEqual(add_docs_counter.times_called, 2)

            actual_docs = []
            exp_services.update_exploration(
                self.owner_id, exp_id, [
                    exp_domain.ExplorationChange({
                        'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                        'property_name': 'category',
                        'new_value': 'cat1'})], 'update category')

            self.process_and_flush_pending_tasks()
            self.assertEqual(actual_docs, [updated_exp_doc])
            self.assertEqual(add_docs_counter.times_called, 3)

    def test_get_number_of_ratings(self) -> None:
        self.save_new_valid_exploration(self.EXP_0_ID, self.owner_id)
        exp = exp_fetchers.get_exploration_summary_by_id(self.EXP_0_ID)

        self.assertEqual(exp_services.get_number_of_ratings(exp.ratings), 0)

        rating_services.assign_rating_to_exploration(
            self.owner_id, self.EXP_0_ID, 5)
        self.assertEqual(
            exp_services.get_number_of_ratings(exp.ratings), 1)

        rating_services.assign_rating_to_exploration(
            self.USER_ID_1, self.EXP_0_ID, 3)
        self.process_and_flush_pending_tasks()
        exp = exp_fetchers.get_exploration_summary_by_id(self.EXP_0_ID)
        self.assertEqual(
            exp_services.get_number_of_ratings(exp.ratings), 2)

        rating_services.assign_rating_to_exploration(
            self.USER_ID_2, self.EXP_0_ID, 5)
        self.process_and_flush_pending_tasks()
        exp = exp_fetchers.get_exploration_summary_by_id(self.EXP_0_ID)
        self.assertEqual(
            exp_services.get_number_of_ratings(exp.ratings), 3)

    def test_get_average_rating(self) -> None:
        self.save_new_valid_exploration(self.EXP_0_ID, self.owner_id)
        exp = exp_fetchers.get_exploration_summary_by_id(self.EXP_0_ID)

        self.assertEqual(
            exp_services.get_average_rating(exp.ratings), 0)

        self.assertEqual(
            exp_services.get_average_rating({}), 0)

        rating_services.assign_rating_to_exploration(
            self.owner_id, self.EXP_0_ID, 5)
        self.assertEqual(
            exp_services.get_average_rating(exp.ratings), 5)

        rating_services.assign_rating_to_exploration(
            self.USER_ID_1, self.EXP_0_ID, 2)

        exp = exp_fetchers.get_exploration_summary_by_id(self.EXP_0_ID)
        self.assertEqual(
            exp_services.get_average_rating(exp.ratings), 3.5)

    def test_get_lower_bound_wilson_rating_from_exp_summary(self) -> None:
        self.save_new_valid_exploration(self.EXP_0_ID, self.owner_id)
        exp = exp_fetchers.get_exploration_summary_by_id(self.EXP_0_ID)

        self.assertEqual(
            exp_services.get_scaled_average_rating(exp.ratings), 0)

        rating_services.assign_rating_to_exploration(
            self.owner_id, self.EXP_0_ID, 5)
        self.assertAlmostEqual(
            exp_services.get_scaled_average_rating(exp.ratings),
            1.8261731658956, places=4)

        rating_services.assign_rating_to_exploration(
            self.USER_ID_1, self.EXP_0_ID, 4)

        exp = exp_fetchers.get_exploration_summary_by_id(self.EXP_0_ID)
        self.assertAlmostEqual(
            exp_services.get_scaled_average_rating(exp.ratings),
            2.056191454757, places=4)

    def test_valid_demo_file_path(self) -> None:
        for filename in os.listdir(feconf.SAMPLE_EXPLORATIONS_DIR):
            full_filepath = os.path.join(
                feconf.SAMPLE_EXPLORATIONS_DIR, filename)
            valid_exploration_path = os.path.isdir(full_filepath) or (
                filename.endswith('yaml'))
            self.assertTrue(valid_exploration_path)

    def test_get_demo_exploration_components_with_invalid_path_raises_error(
        self
    ) -> None:
        with self.assertRaisesRegex(
            Exception, 'Unrecognized file path: invalid_path'):
            exp_services.get_demo_exploration_components('invalid_path')


class ExplorationSummaryTests(ExplorationServicesUnitTests):
    """Test exploration summaries."""

    ALBERT_EMAIL: Final = 'albert@example.com'
    BOB_EMAIL: Final = 'bob@example.com'
    ALBERT_NAME: Final = 'albert'
    BOB_NAME: Final = 'bob'

    EXP_ID_1: Final = 'eid1'
    EXP_ID_2: Final = 'eid2'

    def setUp(self) -> None:
        super().setUp()
        self.signup(self.ALBERT_EMAIL, self.ALBERT_NAME)
        self.signup(self.BOB_EMAIL, self.BOB_NAME)
        self.albert_id = self.get_user_id_from_email(self.ALBERT_EMAIL)
        self.bob_id = self.get_user_id_from_email(self.BOB_EMAIL)

    def test_is_exp_summary_editable(self) -> None:
        self.save_new_default_exploration(self.EXP_0_ID, self.owner_id)

        # Check that only the owner may edit.
        exp_summary = exp_fetchers.get_exploration_summary_by_id(self.EXP_0_ID)
        self.assertTrue(exp_services.is_exp_summary_editable(
            exp_summary, user_id=self.owner_id))
        self.assertFalse(exp_services.is_exp_summary_editable(
            exp_summary, user_id=self.editor_id))
        self.assertFalse(exp_services.is_exp_summary_editable(
            exp_summary, user_id=self.viewer_id))

        # Owner makes viewer a viewer and editor an editor.
        rights_manager.assign_role_for_exploration(
            self.owner, self.EXP_0_ID, self.viewer_id,
            rights_domain.ROLE_VIEWER)
        rights_manager.assign_role_for_exploration(
            self.owner, self.EXP_0_ID, self.editor_id,
            rights_domain.ROLE_EDITOR)

        # Check that owner and editor may edit, but not viewer.
        exp_summary = exp_fetchers.get_exploration_summary_by_id(self.EXP_0_ID)
        self.assertTrue(exp_services.is_exp_summary_editable(
            exp_summary, user_id=self.owner_id))
        self.assertTrue(exp_services.is_exp_summary_editable(
            exp_summary, user_id=self.editor_id))
        self.assertFalse(exp_services.is_exp_summary_editable(
            exp_summary, user_id=self.viewer_id))

    def test_contributors_not_updated_on_revert(self) -> None:
        """Test that a user who only makes a revert on an exploration
        is not counted in the list of that exploration's contributors.
        """
        # Have Albert create a new exploration.
        self.save_new_valid_exploration(self.EXP_ID_1, self.albert_id)
        # Have Albert update that exploration.
        exp_services.update_exploration(
            self.albert_id, self.EXP_ID_1, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'Exploration 1 title'
            })], 'Changed title.')
        # Have Bob revert Albert's update.
        exp_services.revert_exploration(self.bob_id, self.EXP_ID_1, 2, 1)

        # Verify that only Albert (and not Bob, who has not made any non-
        # revert changes) appears in the contributors list for this
        # exploration.
        exploration_summary = exp_fetchers.get_exploration_summary_by_id(
            self.EXP_ID_1)
        self.assertEqual([self.albert_id], exploration_summary.contributor_ids)

    def _check_contributors_summary(
        self, exp_id: str, expected: Dict[str, int]
    ) -> None:
        """Check if contributors summary of the given exp is same as expected.

        Args:
            exp_id: str. The id of the exploration.
            expected: dict(unicode, int). Expected summary.

        Raises:
            AssertionError. Contributors summary of the given exp is not same
                as expected.
        """
        contributors_summary = exp_fetchers.get_exploration_summary_by_id(
            exp_id).contributors_summary
        self.assertEqual(expected, contributors_summary)

    def test_contributors_summary(self) -> None:
        # Have Albert create a new exploration. Version 1.
        self.save_new_valid_exploration(self.EXP_ID_1, self.albert_id)
        self._check_contributors_summary(self.EXP_ID_1, {self.albert_id: 1})

        # Have Bob update that exploration. Version 2.
        exp_services.update_exploration(
            self.bob_id, self.EXP_ID_1, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'Exploration 1 title'
            })], 'Changed title.')
        self.process_and_flush_pending_tasks()
        self._check_contributors_summary(
            self.EXP_ID_1, {self.albert_id: 1, self.bob_id: 1})
        # Have Bob update that exploration. Version 3.
        exp_services.update_exploration(
            self.bob_id, self.EXP_ID_1, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'Exploration 1 title'
            })], 'Changed title.')
        self.process_and_flush_pending_tasks()
        self._check_contributors_summary(
            self.EXP_ID_1, {self.albert_id: 1, self.bob_id: 2})

        # Have Albert update that exploration. Version 4.
        exp_services.update_exploration(
            self.albert_id, self.EXP_ID_1, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'Exploration 1 title'
            })], 'Changed title.')
        self.process_and_flush_pending_tasks()
        self._check_contributors_summary(
            self.EXP_ID_1, {self.albert_id: 2, self.bob_id: 2})

        # Have Albert revert to version 3. Version 5.
        exp_services.revert_exploration(self.albert_id, self.EXP_ID_1, 4, 3)
        self._check_contributors_summary(
            self.EXP_ID_1, {self.albert_id: 1, self.bob_id: 2})

    def test_get_exploration_summary_by_id_with_invalid_exploration_id(
        self
    ) -> None:
        exploration_summary = exp_fetchers.get_exploration_summary_by_id(
            'invalid_exploration_id', strict=False
        )

        self.assertIsNone(exploration_summary)

    def test_create_exploration_summary_with_deleted_contributor(
        self
    ) -> None:
        self.save_new_valid_exploration(
            self.EXP_ID_1, self.albert_id)
        exp_services.update_exploration(
            self.bob_id,
            self.EXP_ID_1,
            [
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                    'property_name': 'title',
                    'new_value': 'Exploration 1 title'
                })
            ],
            'Changed title.')
        exp_services.regenerate_exploration_and_contributors_summaries(
            self.EXP_ID_1)

        self._check_contributors_summary(
            self.EXP_ID_1, {self.albert_id: 1, self.bob_id: 1})

        user_services.mark_user_for_deletion(self.bob_id)
        exp_services.regenerate_exploration_and_contributors_summaries(
            self.EXP_ID_1)

        self._check_contributors_summary(
            self.EXP_ID_1, {self.albert_id: 1})

    def test_regenerate_summary_with_new_contributor_with_invalid_exp_id(
        self
    ) -> None:
        observed_log_messages = []

        def _mock_logging_function(msg: str, *args: str) -> None:
            """Mocks logging.error()."""
            observed_log_messages.append(msg % args)

        logging_swap = self.swap(logging, 'error', _mock_logging_function)
        with logging_swap:
            exp_services.regenerate_exploration_summary_with_new_contributor(
                'dummy_id', self.albert_id)

        self.assertEqual(
            observed_log_messages,
            ['Could not find exploration with ID dummy_id']
        )

    def test_raises_error_while_creating_summary_if_no_created_on_data_present(
        self
    ) -> None:
        self.save_new_valid_exploration('exp_id', 'owner_id')
        exploration = exp_fetchers.get_exploration_by_id('exp_id')
        exp_rights = rights_manager.get_exploration_rights(
            'exp_id', strict=True)
        exploration.created_on = None
        with self.assertRaisesRegex(
            Exception, 'No data available for when the exploration was'
        ):
            exp_services.generate_new_exploration_summary(
                exploration, exp_rights
            )

    def test_raises_error_while_updating_summary_if_no_created_on_data_present(
        self
    ) -> None:
        self.save_new_valid_exploration('exp_id', 'owner_id')
        exploration = exp_fetchers.get_exploration_by_id('exp_id')
        exp_rights = rights_manager.get_exploration_rights(
            'exp_id', strict=True)

        exp_summary = exp_services.generate_new_exploration_summary(
            exploration, exp_rights
        )
        exploration.created_on = None
        with self.assertRaisesRegex(
            Exception, 'No data available for when the exploration was'
        ):
            exp_services.update_exploration_summary(
                exploration, exp_rights, exp_summary
            )


class ExplorationSummaryGetTests(ExplorationServicesUnitTests):
    """Test exploration summaries get_* functions."""

    ALBERT_EMAIL: Final = 'albert@example.com'
    BOB_EMAIL: Final = 'bob@example.com'
    ALBERT_NAME: Final = 'albert'
    BOB_NAME: Final = 'bob'

    EXP_ID_1: Final = 'eid1'
    EXP_ID_2: Final = 'eid2'
    EXP_ID_3: Final = 'eid3'

    EXPECTED_VERSION_1: Final = 4
    EXPECTED_VERSION_2: Final = 2

    def setUp(self) -> None:
        """Populate the database of explorations and their summaries.

        The sequence of events is:
        - (1) Albert creates EXP_ID_1.
        - (2) Bob edits the title of EXP_ID_1.
        - (3) Albert creates EXP_ID_2.
        - (4) Albert edits the title of EXP_ID_1.
        - (5) Albert edits the title of EXP_ID_2.
        - (6) Bob reverts Albert's last edit to EXP_ID_1.
        - Bob tries to publish EXP_ID_2, and is denied access.
        - (7) Albert publishes EXP_ID_2.
        - (8) Albert creates EXP_ID_3.
        - (9) Albert publishes EXP_ID_3.
        - (10) Albert deletes EXP_ID_3.
        """
        super().setUp()

        self.signup(self.ALBERT_EMAIL, self.ALBERT_NAME)
        self.signup(self.BOB_EMAIL, self.BOB_NAME)
        self.albert_id = self.get_user_id_from_email(self.ALBERT_EMAIL)
        self.bob_id = self.get_user_id_from_email(self.BOB_EMAIL)
        self.albert = user_services.get_user_actions_info(self.albert_id)
        self.bob = user_services.get_user_actions_info(self.bob_id)

        self.save_new_valid_exploration(self.EXP_ID_1, self.albert_id)

        exp_services.update_exploration(
            self.bob_id, self.EXP_ID_1, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'Exploration 1 title'
            })], 'Changed title.')

        self.save_new_valid_exploration(self.EXP_ID_2, self.albert_id)

        exp_services.update_exploration(
            self.albert_id, self.EXP_ID_1, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'Exploration 1 Albert title'
            })], 'Changed title to Albert1 title.')

        exp_services.update_exploration(
            self.albert_id, self.EXP_ID_2, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'Exploration 2 Albert title'
            })], 'Changed title to Albert2 title.')

        exp_services.revert_exploration(self.bob_id, self.EXP_ID_1, 3, 2)

        with self.assertRaisesRegex(
            Exception, 'This exploration cannot be published'
            ):
            rights_manager.publish_exploration(self.bob, self.EXP_ID_2)

        rights_manager.publish_exploration(self.albert, self.EXP_ID_2)

        self.save_new_valid_exploration(self.EXP_ID_3, self.albert_id)
        rights_manager.publish_exploration(self.albert, self.EXP_ID_3)
        exp_services.delete_exploration(self.albert_id, self.EXP_ID_3)

    def test_get_non_private_exploration_summaries(self) -> None:

        actual_summaries = exp_services.get_non_private_exploration_summaries()

        expected_summaries = {
            self.EXP_ID_2: exp_domain.ExplorationSummary(
                self.EXP_ID_2, 'Exploration 2 Albert title',
                'Algebra', 'An objective', 'en', [],
                feconf.get_empty_ratings(), feconf.EMPTY_SCALED_AVERAGE_RATING,
                rights_domain.ACTIVITY_STATUS_PUBLIC,
                False, [self.albert_id], [], [], [], [self.albert_id],
                {self.albert_id: 1},
                self.EXPECTED_VERSION_2,
                actual_summaries[self.EXP_ID_2].exploration_model_created_on,
                actual_summaries[self.EXP_ID_2].exploration_model_last_updated,
                actual_summaries[self.EXP_ID_2].first_published_msec
                )}

        # Check actual summaries equal expected summaries.
        self.assertEqual(
            list(actual_summaries.keys()),
            list(expected_summaries.keys()))
        simple_props = ['id', 'title', 'category', 'objective',
                        'language_code', 'tags', 'ratings',
                        'scaled_average_rating', 'status',
                        'community_owned', 'owner_ids',
                        'editor_ids', 'voice_artist_ids', 'viewer_ids',
                        'contributor_ids', 'version',
                        'exploration_model_created_on',
                        'exploration_model_last_updated']
        for exp_id, actual_summary in actual_summaries.items():
            for prop in simple_props:
                self.assertEqual(
                    getattr(actual_summary, prop),
                    getattr(expected_summaries[exp_id], prop))

    def test_get_all_exploration_summaries(self) -> None:
        actual_summaries = exp_services.get_all_exploration_summaries()

        expected_summaries = {
            self.EXP_ID_1: exp_domain.ExplorationSummary(
                self.EXP_ID_1, 'Exploration 1 title',
                'Algebra', 'An objective', 'en', [],
                feconf.get_empty_ratings(), feconf.EMPTY_SCALED_AVERAGE_RATING,
                rights_domain.ACTIVITY_STATUS_PRIVATE, False,
                [self.albert_id], [], [], [], [self.albert_id, self.bob_id],
                {self.albert_id: 1, self.bob_id: 1}, self.EXPECTED_VERSION_1,
                actual_summaries[self.EXP_ID_1].exploration_model_created_on,
                actual_summaries[self.EXP_ID_1].exploration_model_last_updated,
                actual_summaries[self.EXP_ID_1].first_published_msec
            ),
            self.EXP_ID_2: exp_domain.ExplorationSummary(
                self.EXP_ID_2, 'Exploration 2 Albert title',
                'Algebra', 'An objective', 'en', [],
                feconf.get_empty_ratings(), feconf.EMPTY_SCALED_AVERAGE_RATING,
                rights_domain.ACTIVITY_STATUS_PUBLIC,
                False, [self.albert_id], [], [], [], [self.albert_id],
                {self.albert_id: 1}, self.EXPECTED_VERSION_2,
                actual_summaries[self.EXP_ID_2].exploration_model_created_on,
                actual_summaries[self.EXP_ID_2].exploration_model_last_updated,
                actual_summaries[self.EXP_ID_2].first_published_msec
            )
        }

        # Check actual summaries equal expected summaries.
        self.assertItemsEqual(actual_summaries, expected_summaries)

    def test_get_top_rated_exploration_summaries(self) -> None:
        exploration_summaries = (
            exp_services.get_top_rated_exploration_summaries(3))
        top_rated_summaries = (
            exp_models.ExpSummaryModel.get_top_rated(3))
        top_rated_summaries_model = (
            exp_fetchers.get_exploration_summaries_from_models(
                top_rated_summaries))
        self.assertItemsEqual(exploration_summaries, top_rated_summaries_model)

    def test_get_recently_published_exp_summaries(self) -> None:
        self.save_new_valid_exploration(self.EXP_0_ID, self.owner_id)
        self.save_new_valid_exploration(self.EXP_1_ID, self.owner_id)
        self.save_new_valid_exploration(self.EXP_2_ID, self.owner_id)
        rights_manager.publish_exploration(self.owner, self.EXP_0_ID)
        rights_manager.publish_exploration(self.owner, self.EXP_1_ID)
        rights_manager.publish_exploration(self.owner, self.EXP_2_ID)
        exploration_summaries = (
            exp_services.get_recently_published_exp_summaries(3)
        )
        recently_published_summaries = (
            exp_models.ExpSummaryModel.get_recently_published(3))
        recently_publshed_summaries_model = (
            exp_fetchers.get_exploration_summaries_from_models(
                recently_published_summaries))
        self.assertEqual(len(exploration_summaries), 3)
        self.assertItemsEqual(
            exploration_summaries,
            recently_publshed_summaries_model)

    def test_get_story_id_linked_to_exploration(self) -> None:
        self.assertIsNone(
            exp_services.get_story_id_linked_to_exploration(self.EXP_ID_1))
        story_id = story_services.get_new_story_id()
        topic_id = topic_fetchers.get_new_topic_id()
        self.save_new_topic(
            topic_id, self.albert_id, name='Topic',
            abbreviated_name='topic-one', url_fragment='topic-one',
            description='A new topic',
            canonical_story_ids=[], additional_story_ids=[],
            uncategorized_skill_ids=['skill_4'], subtopics=[],
            next_subtopic_id=0)
        self.save_new_story(story_id, self.albert_id, topic_id)
        topic_services.add_canonical_story(self.albert_id, topic_id, story_id)
        change_list = [
            story_domain.StoryChange({
                'cmd': story_domain.CMD_ADD_STORY_NODE,
                'node_id': story_domain.NODE_ID_PREFIX + '1',
                'title': 'Title 1'
            }),
            story_domain.StoryChange({
                'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY,
                'property_name': (
                    story_domain.STORY_NODE_PROPERTY_EXPLORATION_ID),
                'node_id': story_domain.NODE_ID_PREFIX + '1',
                'old_value': None,
                'new_value': self.EXP_ID_1
            })
        ]
        story_services.update_story(
            self.albert_id, story_id, change_list,
            'Added node.')
        self.assertEqual(
            exp_services.get_story_id_linked_to_exploration(self.EXP_ID_1),
            story_id)

    def test_get_user_exploration_data(self) -> None:
        self.save_new_valid_exploration(self.EXP_0_ID, self.albert_id)
        exploration_description = (
            exp_services.get_user_exploration_data(
                self.albert_id, self.EXP_0_ID))
        self.assertIsNotNone(exploration_description)

        exploration = self.save_new_valid_exploration(
            self.EXP_0_ID,
            self.albert_id)
        exploration.param_specs = {
            'myParam': param_domain.ParamSpec('UnicodeString')}
        init_state_name = exploration.init_state_name
        param_changes = [{
            'customization_args': {
                'list_of_values': ['1', '2'], 'parse_with_jinja': False
            },
            'name': 'myParam',
            'generator_id': 'RandomSelector'
        }]
        draft_change_list = _get_change_list(
            init_state_name, 'param_changes', param_changes)
        draft_change_list_dict = [
            change.to_dict() for change in draft_change_list]
        date_time = datetime.datetime.strptime('2016-02-16', '%Y-%m-%d')
        user_models.ExplorationUserDataModel(
            id='%s.%s' % (self.albert_id, self.EXP_0_ID),
            user_id=self.albert_id,
            exploration_id=self.EXP_0_ID,
            draft_change_list=draft_change_list_dict,
            draft_change_list_last_updated=date_time,
            draft_change_list_exp_version=1,
            draft_change_list_id=2).put()
        exploration_description_draft_applied = (
            exp_services.get_user_exploration_data(
                self.albert_id,
                self.EXP_0_ID,
                True))
        self.assertTrue(
            exploration_description_draft_applied['is_version_of_draft_valid'])
        self.save_new_valid_exploration(self.EXP_1_ID, self.bob_id)
        exploration_draft_not_applied = (
            exp_services.get_user_exploration_data(
                self.bob_id, self.EXP_1_ID, True))
        self.assertFalse(
            exploration_draft_not_applied['is_version_of_draft_valid'])


class ExplorationConversionPipelineTests(ExplorationServicesUnitTests):
    """Tests the exploration model -> exploration conversion pipeline."""

    NEW_EXP_ID: Final = 'exp_id1'

    UPGRADED_EXP_YAML: Final = (
        """author_notes: ''
auto_tts_enabled: true
blurb: ''
category: category
edits_allowed: true
init_state_name: %r
language_code: en
objective: Old objective
param_changes: []
param_specs: {}
schema_version: %d
states:
  END:
    classifier_model_id: null
    content:
      content_id: content
      html: <p>Congratulations, you have finished!</p>
    interaction:
      answer_groups: []
      confirmed_unclassified_answers: []
      customization_args:
        recommendedExplorationIds:
          value: []
      default_outcome: null
      hints: []
      id: EndExploration
      solution: null
    linked_skill_id: null
    next_content_id_index: 0
    param_changes: []
    recorded_voiceovers:
      voiceovers_mapping:
        content: {}
    solicit_answer_details: false
  %r:
    classifier_model_id: null
    content:
      content_id: content
      html: ''
    interaction:
      answer_groups: []
      confirmed_unclassified_answers: []
      customization_args:
        buttonText:
          value:
            content_id: ca_buttonText
            unicode_str: Continue
      default_outcome:
        dest: END
        dest_if_really_stuck: null
        feedback:
          content_id: default_outcome
          html: ''
        labelled_as_correct: false
        missing_prerequisite_skill_id: null
        param_changes: []
        refresher_exploration_id: null
      hints: []
      id: Continue
      solution: null
    linked_skill_id: null
    next_content_id_index: 0
    param_changes: []
    recorded_voiceovers:
      voiceovers_mapping:
        ca_buttonText: {}
        content: {}
        default_outcome: {}
    solicit_answer_details: false
states_schema_version: %d
tags: []
title: Old Title
""") % (
    feconf.DEFAULT_INIT_STATE_NAME.encode('utf-8'),
    exp_domain.Exploration.CURRENT_EXP_SCHEMA_VERSION,
    feconf.DEFAULT_INIT_STATE_NAME.encode('utf-8'),
    feconf.CURRENT_STATE_SCHEMA_VERSION)

    ALBERT_EMAIL = 'albert@example.com'
    ALBERT_NAME = 'albert'

    def setUp(self) -> None:
        super().setUp()

        # Setup user who will own the test explorations.
        self.signup(self.ALBERT_EMAIL, self.ALBERT_NAME)
        self.albert_id = self.get_user_id_from_email(self.ALBERT_EMAIL)

        # Create standard exploration that should not be converted.
        new_exp = self.save_new_valid_exploration(
            self.NEW_EXP_ID, self.albert_id)
        self._up_to_date_yaml = new_exp.to_yaml()

    def test_get_exploration_from_model_with_invalid_schema_version_raise_error(
        self
    ) -> None:
        exp_model = exp_models.ExplorationModel(
            id='exp_id',
            category='category',
            title='title',
            objective='Old objective',
            states_schema_version=(feconf.CURRENT_STATE_SCHEMA_VERSION + 1),
            init_state_name=feconf.DEFAULT_INIT_STATE_NAME
        )

        rights_manager.create_new_exploration_rights('exp_id', self.albert_id)

        exp_model.commit(
            self.albert_id, 'New exploration created', [{
                'cmd': 'create_new',
                'title': 'title',
                'category': 'category',
            }])

        with self.assertRaisesRegex(
            Exception,
            'Sorry, we can only process v41-v%d exploration state schemas at '
            'present.' % feconf.CURRENT_STATE_SCHEMA_VERSION):
            exp_fetchers.get_exploration_from_model(exp_model)

    def test_update_exploration_by_voice_artist(self) -> None:
        exp_id = 'exp_id'
        user_id = 'user_id'
        self.save_new_default_exploration(exp_id, user_id)
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'title',
            'new_value': 'new title'
            })]
        with self.assertRaisesRegex(
            utils.ValidationError,
            'Voice artist does not have permission to make some '
            'changes in the change list.'):
            exp_services.update_exploration(
                user_id, exp_id, change_list, 'By voice artist', True)

    def test_update_exploration_linked_to_story(self) -> None:
        story_id = story_services.get_new_story_id()
        topic_id = topic_fetchers.get_new_topic_id()
        exp_id = 'exp_id'
        user_id = 'user_id'
        self.save_new_default_exploration(exp_id, user_id)
        self.save_new_topic(
            topic_id, user_id, name='Topic',
            abbreviated_name='topic-one', url_fragment='topic-one',
            description='A new topic',
            canonical_story_ids=[], additional_story_ids=[],
            uncategorized_skill_ids=['skill_4'], subtopics=[],
            next_subtopic_id=0)
        self.save_new_story(story_id, user_id, topic_id)
        topic_services.add_canonical_story(user_id, topic_id, story_id)
        change_list_story = [
            story_domain.StoryChange({
                'cmd': story_domain.CMD_ADD_STORY_NODE,
                'node_id': story_domain.NODE_ID_PREFIX + '1',
                'title': 'Title 1'
            }),
            story_domain.StoryChange({
                'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY,
                'property_name': (
                    story_domain.STORY_NODE_PROPERTY_EXPLORATION_ID),
                'node_id': story_domain.NODE_ID_PREFIX + '1',
                'old_value': None,
                'new_value': exp_id
            })
        ]
        story_services.update_story(
            user_id, story_id, change_list_story,
            'Added node.')
        change_list_exp = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'title',
            'new_value': 'new title'
            })]
        opportunity_services.add_new_exploration_opportunities(
            story_id, [exp_id])
        exp_services.update_exploration(
            user_id, exp_id, change_list_exp, 'story linked')
        updated_exp = exp_fetchers.get_exploration_by_id(exp_id)
        self.assertEqual(updated_exp.title, 'new title')

    def test_update_exploration_with_empty_change_list_does_not_update(
        self
    ) -> None:
        exploration = self.save_new_default_exploration('exp_id', 'user_id')

        self.assertEqual(exploration.title, 'A title')
        self.assertEqual(exploration.category, 'Algebra')
        self.assertEqual(
            exploration.objective, feconf.DEFAULT_EXPLORATION_OBJECTIVE)
        self.assertEqual(exploration.language_code, 'en')

        exp_services.update_exploration(
            'user_id', 'exp_id', [], 'empty commit')

        exploration = exp_fetchers.get_exploration_by_id('exp_id')

        self.assertEqual(exploration.title, 'A title')
        self.assertEqual(exploration.category, 'Algebra')
        self.assertEqual(
            exploration.objective, feconf.DEFAULT_EXPLORATION_OBJECTIVE)
        self.assertEqual(exploration.language_code, 'en')

    def test_save_exploration_with_mismatch_of_versions_raises_error(
        self
    ) -> None:
        self.save_new_valid_exploration('exp_id', 'user_id')
        exploration_model = exp_models.ExplorationModel.get('exp_id')
        exploration = exp_fetchers.get_exploration_from_model(exploration_model)
        exploration.version = 2

        def _mock_apply_change_list(
            *unused_args: str, **unused_kwargs: str
        ) -> exp_domain.Exploration:
            """Mocks exp_fetchers.get_exploration_by_id()."""
            return exploration

        fetch_swap = self.swap(
            exp_services, 'apply_change_list',
            _mock_apply_change_list)

        with fetch_swap, self.assertRaisesRegex(
            Exception,
            'Unexpected error: trying to update version 1 of exploration '
            'from version 2. Please reload the page and try again.'):
            exp_services.update_exploration(
                'user_id', 'exp_id', [exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                    'property_name': 'title',
                    'new_value': 'new title'
                })], 'changed title')

    def test_update_title(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.language_code, 'en')
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.title, 'new title')

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'language_code',
                'new_value': 'bn'
            })], 'Changed language code.')

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'title',
            'new_value': 'new changed title'
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, change_list, 'Changed title.')

        # Assert that final version consists all the changes.
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.language_code, 'bn')
        self.assertEqual(exploration.title, 'new changed title')

    def test_update_language_code(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.language_code, 'en')
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'language_code',
                'new_value': 'bn'
            })], 'Changed language code.')

        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.language_code, 'bn')

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'language_code',
            'new_value': 'en'
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, change_list,
            'Changed language code again.')

        # Assert that final version consists all the changes.
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.title, 'new title')
        self.assertEqual(exploration.language_code, 'en')

    def test_update_exploration_tags(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.tags, [])
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'tags',
                'new_value': ['test']
            })], 'Changed tags.')

        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.tags, ['test'])

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'tags',
            'new_value': ['test', 'skill']
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, change_list,
            'Changed tags.')

        # Assert that final version consists all the changes.
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.title, 'new title')
        self.assertEqual(exploration.tags, ['test', 'skill'])

    def test_update_exploration_author_notes(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.author_notes, '')
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'author_notes',
                'new_value': 'author_notes'
            })], 'Changed author_notes.')

        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.author_notes, 'author_notes')

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'author_notes',
            'new_value': 'author_notes_updated_again'
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, change_list,
            'Changed author_notes.')

        # Assert that final version consists all the changes.
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.title, 'new title')
        self.assertEqual(exploration.author_notes, 'author_notes_updated_again')

    def test_update_exploration_blurb(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.blurb, '')
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'blurb',
                'new_value': 'blurb'
            })], 'Changed blurb.')

        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.blurb, 'blurb')

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'blurb',
            'new_value': 'blurb_changed'
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, change_list,
            'Changed blurb.')

        # Assert that final version consists all the changes.
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.title, 'new title')
        self.assertEqual(exploration.blurb, 'blurb_changed')

    def test_update_exploration_param_changes(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.param_changes, [])

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'param_specs',
            'new_value': {
                'myParam': {'obj_type': 'UnicodeString'}
            }
        })]
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, change_list, '')

        param_changes: List[param_domain.ParamChangeDict] = [{
            'customization_args': {
                'list_of_values': ['1', '2'], 'parse_with_jinja': False
            },
            'name': 'myParam',
            'generator_id': 'RandomSelector'
        }]

        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'param_changes',
                'new_value': param_changes
            })], 'Changed param_changes.')

        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)

        self.assertEqual(len(exploration.param_changes), 1)
        self.assertEqual(
            exploration.param_changes[0].to_dict(), param_changes[0])

    def test_update_exploration_init_state_name(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        content_id_generator = translation_domain.ContentIdGenerator(
            exploration.next_content_id_index
        )
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'State',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }),
            exp_domain.ExplorationChange({
                'cmd': 'edit_exploration_property',
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index
            })], 'Added new state.')

        self.assertEqual(
            exploration.init_state_name, feconf.DEFAULT_INIT_STATE_NAME)

        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                    'property_name': 'init_state_name',
                    'new_value': 'State',
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'state_name': 'State',
                    'property_name': 'card_is_checkpoint',
                    'new_value': True,
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'state_name': feconf.DEFAULT_INIT_STATE_NAME,
                    'property_name': 'card_is_checkpoint',
                    'new_value': False,
                }),
            ], 'Changed init_state_name and checkpoints.')

        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.init_state_name, 'State')

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'init_state_name',
            'new_value': feconf.DEFAULT_INIT_STATE_NAME,
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'state_name': 'State',
            'property_name': 'card_is_checkpoint',
            'new_value': False,
        }), exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'state_name': feconf.DEFAULT_INIT_STATE_NAME,
            'property_name': 'card_is_checkpoint',
            'new_value': True,
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 3, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, change_list,
            'Changed init_state_name and checkpoints again.')

        # Assert that final version consists all the changes.
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.title, 'new title')
        self.assertEqual(
            exploration.init_state_name, feconf.DEFAULT_INIT_STATE_NAME)

    def test_update_exploration_auto_tts_enabled(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.auto_tts_enabled, False)
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'auto_tts_enabled',
                'new_value': False
            })], 'Changed auto_tts_enabled.')

        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.auto_tts_enabled, False)

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'auto_tts_enabled',
            'new_value': True
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, change_list,
            'Changed auto_tts_enabled again.')

        # Assert that final version consists all the changes.
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.title, 'new title')
        self.assertEqual(exploration.auto_tts_enabled, True)

    def test_update_old_exploration_version_remains_editable(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.language_code, 'en')
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'language_code',
                'new_value': 'hi'
            })], 'Changed language code.')

        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.language_code, 'hi')

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'language_code',
            'new_value': 'en'
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, change_list,
            'Changed language code again.')

        # Assert that final version consists all the changes.
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.title, 'new title')
        self.assertEqual(exploration.language_code, 'en')

    def test_update_exploration_with_mark_translation_needs_update_changes(
        self
    ) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        translation_services.add_new_translation(
            feconf.TranslatableEntityType.EXPLORATION, self.NEW_EXP_ID,
            exploration.version, 'hi', 'content_0',
            translation_domain.TranslatedContent(
                'Translation',
                translation_domain.TranslatableContentFormat.HTML,
                False
            )
        )
        entity_translations = (
            translation_fetchers.get_all_entity_translations_for_entity(
                feconf.TranslatableEntityType.EXPLORATION, self.NEW_EXP_ID,
                exploration.version
            )
        )
        self.assertEqual(len(entity_translations), 1)
        self.assertFalse(
            entity_translations[0].translations['content_0'].needs_update)

        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_MARK_TRANSLATIONS_NEEDS_UPDATE,
                'content_id': 'content_0'
            })], 'Marked translation need update.')
        entity_translations = (
            translation_fetchers.get_all_entity_translations_for_entity(
                feconf.TranslatableEntityType.EXPLORATION, self.NEW_EXP_ID,
                exploration.version + 1
            )
        )
        self.assertEqual(len(entity_translations), 1)
        self.assertTrue(
            entity_translations[0].translations['content_0'].needs_update)

    def test_update_exploration_with_remove_translation_changes(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)

        translation_services.add_new_translation(
            feconf.TranslatableEntityType.EXPLORATION, self.NEW_EXP_ID,
            exploration.version, 'hi', 'content_0',
            translation_domain.TranslatedContent(
                'Translation 1',
                translation_domain.TranslatableContentFormat.HTML,
                False
            )
        )
        translation_services.add_new_translation(
            feconf.TranslatableEntityType.EXPLORATION, self.NEW_EXP_ID,
            exploration.version, 'hi', 'default_outcome_1',
            translation_domain.TranslatedContent(
                'Translation 2',
                translation_domain.TranslatableContentFormat.HTML,
                False
            )
        )
        entity_translations = (
            translation_fetchers.get_all_entity_translations_for_entity(
                feconf.TranslatableEntityType.EXPLORATION, self.NEW_EXP_ID,
                exploration.version
            )
        )
        self.assertEqual(len(entity_translations), 1)
        self.assertTrue('content_0' in entity_translations[0].translations)
        self.assertTrue(
            'default_outcome_1' in entity_translations[0].translations)

        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_REMOVE_TRANSLATIONS,
                'content_id': 'content_0'
            })], 'Marked translation need update.')

        entity_translations = (
            translation_fetchers.get_all_entity_translations_for_entity(
                feconf.TranslatableEntityType.EXPLORATION, self.NEW_EXP_ID,
                exploration.version + 1
            )
        )
        self.assertEqual(len(entity_translations), 1)
        self.assertFalse('content_0' in entity_translations[0].translations)
        self.assertTrue(
            'default_outcome_1' in entity_translations[0].translations)

    def test_update_unclassified_answers(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(
            exploration.init_state.interaction.confirmed_unclassified_answers,
            [])

        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'property_name': exp_domain.STATE_PROPERTY_UNCLASSIFIED_ANSWERS,
                'state_name': exploration.init_state_name,
                'new_value': ['test']
            })], 'Changed confirmed_unclassified_answers.')

        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(
            exploration.init_state.interaction.confirmed_unclassified_answers,
            ['test'])

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name': exp_domain.STATE_PROPERTY_UNCLASSIFIED_ANSWERS,
            'state_name': exploration.init_state_name,
            'new_value': ['test', 'skill']
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, change_list,
            'Changed confirmed_unclassified_answers.')

        # Assert that final version consists all the changes.
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.title, 'new title')
        self.assertEqual(
            exploration.init_state.interaction.confirmed_unclassified_answers,
            ['test', 'skill'])

    def test_update_interaction_hints(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(
            exploration.init_state.interaction.hints, [])

        hint_list: List[state_domain.HintDict] = [{
            'hint_content': {
                'content_id': 'hint_1',
                'html': (
                    '<p>Hello, this is html1 for state2'
                    '<oppia-noninteractive-image filepath-with-value="'
                    '&amp;quot;s2Hint1.png&amp;quot;" caption-with-value='
                    '"&amp;quot;&amp;quot;" alt-with-value='
                    '"&amp;quot;image&amp;quot;"></oppia-noninteractive-image>'
                    '</p>')
            }
        }]

        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'property_name': exp_domain.STATE_PROPERTY_INTERACTION_HINTS,
                'state_name': exploration.init_state_name,
                'new_value': hint_list
            })], 'Changed hints.')

        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)

        self.assertEqual(len(exploration.init_state.interaction.hints), 1)
        self.assertEqual(
            exploration.init_state.interaction.hints[0].hint_content.content_id,
            'hint_1')

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')

        hint_list_2: List[state_domain.HintDict] = [{
            'hint_content': {
                'content_id': 'hint_1',
                'html': (
                    '<p>Hello, this is html1 for state2'
                    '<oppia-noninteractive-image filepath-with-value="'
                    '&amp;quot;s2Hint1.png&amp;quot;" caption-with-value='
                    '"&amp;quot;&amp;quot;" alt-with-value='
                    '"&amp;quot;image&amp;quot;"></oppia-noninteractive-image>'
                    '</p>')
            }
        }, {
            'hint_content': {
                'content_id': 'hint_2',
                'html': (
                    '<p>Hello, this is html1 for state2'
                    '<oppia-noninteractive-image filepath-with-value="'
                    '&amp;quot;s2Hint1.png&amp;quot;" caption-with-value='
                    '"&amp;quot;&amp;quot;" alt-with-value='
                    '"&amp;quot;image&amp;quot;"></oppia-noninteractive-image>'
                    '</p>')
            }
        }]

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name': exp_domain.STATE_PROPERTY_INTERACTION_HINTS,
            'state_name': exploration.init_state_name,
            'new_value': hint_list_2
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 2, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, change_list, 'Changed hints.')

        # Assert that final version consists all the changes.
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.title, 'new title')
        self.assertEqual(len(exploration.init_state.interaction.hints), 2)
        self.assertEqual(
            exploration.init_state.interaction.hints[0].hint_content.content_id,
            'hint_1')
        self.assertEqual(
            exploration.init_state.interaction.hints[1].hint_content.content_id,
            'hint_2')

    def test_update_interaction_hints_invalid_parameter_type(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(
            exploration.init_state.interaction.hints, [])

        # The passed hints should be a list.
        hint_dict = {
            'hint_content': {
                'content_id': 'hint_1',
                'html': (
                    '<p>Hello, this is html1 for state2'
                    '<oppia-noninteractive-image filepath-with-value="'
                    '&amp;quot;s2Hint1.png&amp;quot;" caption-with-value='
                    '"&amp;quot;&amp;quot;" alt-with-value='
                    '"&amp;quot;image&amp;quot;"></oppia-noninteractive-image>'
                    '</p>')
            }
        }

        with self.assertRaisesRegex(
            Exception, 'Expected hints_list to be a list.*'):
            hints_update = exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'property_name': exp_domain.STATE_PROPERTY_INTERACTION_HINTS,
                'state_name': exploration.init_state_name,
                'new_value': hint_dict
            })
            exp_services.update_exploration(
                self.albert_id, self.NEW_EXP_ID, [hints_update],
                'Changed hints.'
            )

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')

        hint_dict = {
            'hint_content': {
                'content_id': 'hint_1',
                'html': (
                    '<p>Hello, this is html1 for state2'
                    '<oppia-noninteractive-image filepath-with-value="'
                    '&amp;quot;s2Hint1.png&amp;quot;" caption-with-value='
                    '"&amp;quot;&amp;quot;" alt-with-value='
                    '"&amp;quot;image&amp;quot;"></oppia-noninteractive-image>'
                    '</p>')
            }
        }

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name': exp_domain.STATE_PROPERTY_INTERACTION_HINTS,
            'state_name': exploration.init_state_name,
            'new_value': hint_dict
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 1, change_list)
        self.assertTrue(changes_are_mergeable)
        with self.assertRaisesRegex(
            Exception, 'Expected hints_list to be a list.*'):
            exp_services.update_exploration(
                self.albert_id, self.NEW_EXP_ID, change_list,
                'Changed hints.')

        # Assert that final version consists all the changes.
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.title, 'new title')

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')

        hint_dict = {
            'hint_content': {
                'content_id': 'hint_1',
                'html': (
                    '<p>Hello, this is html1 for state2'
                    '<oppia-noninteractive-image filepath-with-value="'
                    '&amp;quot;s2Hint1.png&amp;quot;" caption-with-value='
                    '"&amp;quot;&amp;quot;" alt-with-value='
                    '"&amp;quot;image&amp;quot;"></oppia-noninteractive-image>'
                    '</p>')
            }
        }

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name': exp_domain.STATE_PROPERTY_INTERACTION_HINTS,
            'state_name': exploration.init_state_name,
            'new_value': hint_dict
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 1, change_list)
        self.assertTrue(changes_are_mergeable)
        with self.assertRaisesRegex(
            Exception, 'Expected hints_list to be a list.*'):
            exp_services.update_exploration(
                self.albert_id, self.NEW_EXP_ID, change_list,
                'Changed hints.')

        # Assert that final version consists all the changes.
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(exploration.title, 'new title')

    def test_update_interaction_solutions(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertIsNone(exploration.init_state.interaction.solution)

        solution: Optional[state_domain.SolutionDict] = {
            'answer_is_exclusive': False,
            'correct_answer': 'helloworld!',
            'explanation': {
                'content_id': 'solution',
                'html': '<p>hello_world is a string</p>'
            },
        }

        hint_list: List[state_domain.HintDict] = [{
            'hint_content': {
                'content_id': u'hint_1',
                'html': (
                    u'<p>Hello, this is html1 for state2'
                    u'<oppia-noninteractive-image filepath-with-value="'
                    u'&amp;quot;s2Hint1.png&amp;quot;" caption-with-value='
                    u'"&amp;quot;&amp;quot;" alt-with-value='
                    u'"&amp;quot;image&amp;quot;"></oppia-noninteractive-image>'
                    u'</p>')
            }
        }]

        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'property_name': exp_domain.STATE_PROPERTY_INTERACTION_HINTS,
                'state_name': exploration.init_state_name,
                'new_value': hint_list
            })], 'Changed hints.')

        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'property_name': exp_domain.STATE_PROPERTY_INTERACTION_SOLUTION,
                'state_name': exploration.init_state_name,
                'new_value': solution
            })], 'Changed interaction_solutions.')

        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        # Ruling out the possibility of None for mypy type checking.
        assert exploration.init_state.interaction.solution is not None
        self.assertEqual(
            exploration.init_state.interaction.solution.to_dict(),
            solution)
        solution = None
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                'property_name': exp_domain.STATE_PROPERTY_INTERACTION_SOLUTION,
                'state_name': exploration.init_state_name,
                'new_value': solution
            })], 'Changed interaction_solutions.')
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        self.assertEqual(
            exploration.init_state.interaction.solution,
            None)

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')

        solution_2 = {
            'answer_is_exclusive': False,
            'correct_answer': 'helloworld!',
            'explanation': {
                'content_id': 'solution',
                'html': '<p>hello_oppia is a string</p>'
            },
        }

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name': exp_domain.STATE_PROPERTY_INTERACTION_SOLUTION,
            'state_name': exploration.init_state_name,
            'new_value': solution_2
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 4, change_list)
        self.assertTrue(changes_are_mergeable)
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, change_list,
            'Changed interaction_solutions.')

        # Assert that final version consists all the changes.
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)
        # Ruling out the possibility of None for mypy type checking.
        assert exploration.init_state.interaction.solution is not None
        self.assertEqual(exploration.title, 'new title')
        self.assertEqual(
            exploration.init_state.interaction.solution.to_dict(),
            solution_2)

    def test_cannot_update_recorded_voiceovers_with_invalid_type(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.NEW_EXP_ID)

        with self.assertRaisesRegex(
            Exception, 'Expected recorded_voiceovers to be a dict'):
            exp_services.update_exploration(
                self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name': (
                        exp_domain.STATE_PROPERTY_RECORDED_VOICEOVERS),
                    'state_name': exploration.init_state_name,
                    'new_value': 'invalid_recorded_voiceovers'
                })], 'Changed recorded_voiceovers.')

        # Check that the property can be changed when working
        # on old version.
        # Add change to upgrade the version.
        exp_services.update_exploration(
            self.albert_id, self.NEW_EXP_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'new title'
            })], 'Changed title.')

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name': (
                exp_domain.STATE_PROPERTY_RECORDED_VOICEOVERS),
            'state_name': exploration.init_state_name,
            'new_value': 'invalid_recorded_voiceovers'
        })]
        changes_are_mergeable = exp_services.are_changes_mergeable(
            self.NEW_EXP_ID, 1, change_list)
        self.assertTrue(changes_are_mergeable)
        with self.assertRaisesRegex(
            Exception, 'Expected recorded_voiceovers to be a dict'):
            exp_services.update_exploration(
                self.albert_id, self.NEW_EXP_ID, change_list,
                'Changed recorded_voiceovers.')

    def test_get_exploration_validation_error(self) -> None:
        # Valid exploration version.
        info = exp_services.get_exploration_validation_error(
            self.NEW_EXP_ID, 0)
        self.assertIsNone(info)

        # Invalid exploration version.
        def _mock_exploration_validate_function(
            *args: str, **kwargs: str
        ) -> None:
            """Mocks exploration.validate()."""
            raise utils.ValidationError('Bad')

        validate_swap = self.swap(
            exp_domain.Exploration, 'validate',
            _mock_exploration_validate_function)
        with validate_swap:
            info = exp_services.get_exploration_validation_error(
                self.NEW_EXP_ID, 0)
            self.assertEqual(info, 'Bad')

    def test_revert_exploration_after_publish(self) -> None:
        self.save_new_valid_exploration(
            self.EXP_0_ID, self.albert_id,
            end_state_name='EndState')
        exploration_model = exp_fetchers.get_exploration_by_id(self.EXP_0_ID)
        exp_services.update_exploration(
            self.albert_id, self.EXP_0_ID, [
                exp_domain.ExplorationChange({
                    'cmd': 'edit_exploration_property',
                    'property_name': 'title',
                    'new_value': 'New title'
            })], 'Changed title')
        user_actions_info = user_services.get_user_actions_info(self.albert_id)
        rights_manager.publish_exploration(user_actions_info, self.EXP_0_ID)
        updated_exploration_model = exp_fetchers.get_exploration_by_id(
            self.EXP_0_ID)
        exp_services.revert_exploration(
            self.albert_id, self.EXP_0_ID, updated_exploration_model.version, 1)
        reverted_exploration = exp_fetchers.get_exploration_by_id(
            self.EXP_0_ID)
        self.assertEqual(exploration_model.title, reverted_exploration.title)
        self.assertEqual(3, reverted_exploration.version)

    def test_revert_exploration_with_mismatch_of_versions_raises_error(
        self
    ) -> None:
        self.save_new_valid_exploration('exp_id', 'user_id')

        exploration_model = exp_models.ExplorationModel.get('exp_id')
        exploration_model.version = 0

        with self.assertRaisesRegex(
            Exception,
            'Unexpected error: trying to update version 0 of exploration '
            'from version 1. Please reload the page and try again.'):
            exp_services.revert_exploration('user_id', 'exp_id', 1, 0)


class EditorAutoSavingUnitTests(test_utils.GenericTestBase):
    """Test editor auto saving functions in exp_services."""

    EXP_ID1: Final = 'exp_id1'
    EXP_ID2: Final = 'exp_id2'
    EXP_ID3: Final = 'exp_id3'
    USERNAME: Final = 'user123'
    USER_ID: Final = 'user_id'
    COMMIT_MESSAGE: Final = 'commit message'
    DATETIME: Final = datetime.datetime.strptime('2016-02-16', '%Y-%m-%d')
    OLDER_DATETIME: Final = datetime.datetime.strptime('2016-01-16', '%Y-%m-%d')
    NEWER_DATETIME: Final = datetime.datetime.strptime('2016-03-16', '%Y-%m-%d')
    NEW_CHANGELIST: Final = [exp_domain.ExplorationChange({
        'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
        'property_name': 'title',
        'new_value': 'New title'})]
    NEW_CHANGELIST_DICT: Final = [NEW_CHANGELIST[0].to_dict()]

    def setUp(self) -> None:
        super().setUp()
        self.signup(self.EDITOR_EMAIL, self.EDITOR_USERNAME)
        self.editor_id = self.get_user_id_from_email(self.EDITOR_EMAIL)
        self.signup(self.CURRICULUM_ADMIN_EMAIL, self.CURRICULUM_ADMIN_USERNAME)
        self.admin_id = self.get_user_id_from_email(self.CURRICULUM_ADMIN_EMAIL)
        self.admin = user_services.get_user_actions_info(self.admin_id)
        self.set_curriculum_admins([self.CURRICULUM_ADMIN_USERNAME])
        # Create explorations.
        exploration = self.save_new_valid_exploration(
            self.EXP_ID1, self.USER_ID)
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'param_specs',
            'new_value': {
                'myParam': {'obj_type': 'UnicodeString'}
            }
        })]
        exp_services.update_exploration(
            self.USER_ID, self.EXP_ID1, change_list, '')
        self.save_new_valid_exploration(self.EXP_ID2, self.USER_ID)
        self.save_new_valid_exploration(self.EXP_ID3, self.USER_ID)
        self.init_state_name = exploration.init_state_name
        self.param_changes = [{
            'customization_args': {
                'list_of_values': ['1', '2'], 'parse_with_jinja': False
            },
            'name': 'myParam',
            'generator_id': 'RandomSelector'
        }]
        self.draft_change_list = _get_change_list(
            self.init_state_name, 'param_changes', self.param_changes)
        self.draft_change_list_dict = [
            change.to_dict() for change in self.draft_change_list]
        # Explorations with draft set.
        user_models.ExplorationUserDataModel(
            id='%s.%s' % (self.USER_ID, self.EXP_ID1), user_id=self.USER_ID,
            exploration_id=self.EXP_ID1,
            draft_change_list=self.draft_change_list_dict,
            draft_change_list_last_updated=self.DATETIME,
            draft_change_list_exp_version=2,
            draft_change_list_id=2).put()
        user_models.ExplorationUserDataModel(
            id='%s.%s' % (self.USER_ID, self.EXP_ID2), user_id=self.USER_ID,
            exploration_id=self.EXP_ID2,
            draft_change_list=self.draft_change_list_dict,
            draft_change_list_last_updated=self.DATETIME,
            draft_change_list_exp_version=4,
            draft_change_list_id=10).put()
        # Exploration with no draft.
        user_models.ExplorationUserDataModel(
            id='%s.%s' % (self.USER_ID, self.EXP_ID3), user_id=self.USER_ID,
            exploration_id=self.EXP_ID3).put()

    def test_draft_cleared_after_change_list_applied(self) -> None:
        exp_services.update_exploration(
            self.USER_ID, self.EXP_ID1, self.draft_change_list, '')
        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
            '%s.%s' % (self.USER_ID, self.EXP_ID1))
        self.assertIsNone(exp_user_data.draft_change_list)
        self.assertIsNone(exp_user_data.draft_change_list_last_updated)
        self.assertIsNone(exp_user_data.draft_change_list_exp_version)

    def test_draft_version_valid_returns_true(self) -> None:
        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
            '%s.%s' % (self.USER_ID, self.EXP_ID1))
        self.assertTrue(exp_services.is_version_of_draft_valid(
            self.EXP_ID1, exp_user_data.draft_change_list_exp_version))

    def test_draft_version_valid_returns_false(self) -> None:
        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
            '%s.%s' % (self.USER_ID, self.EXP_ID2))
        self.assertFalse(exp_services.is_version_of_draft_valid(
            self.EXP_ID2, exp_user_data.draft_change_list_exp_version))

    def test_draft_version_valid_when_no_draft_exists(self) -> None:
        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
            '%s.%s' % (self.USER_ID, self.EXP_ID3))
        self.assertFalse(exp_services.is_version_of_draft_valid(
            self.EXP_ID3, exp_user_data.draft_change_list_exp_version))

    def test_create_or_update_draft_when_by_voice_artist(self) -> None:
        with self.assertRaisesRegex(
                utils.ValidationError,
                'Voice artist does not have permission to make some '
                'changes in the change list.'):
            exp_services.create_or_update_draft(
                self.EXP_ID1, self.USER_ID, self.NEW_CHANGELIST, 5,
                self.NEWER_DATETIME, True)

    def test_create_or_update_draft_when_older_draft_exists(self) -> None:
        exp_services.create_or_update_draft(
            self.EXP_ID1, self.USER_ID, self.NEW_CHANGELIST, 5,
            self.NEWER_DATETIME)
        exp_user_data = user_models.ExplorationUserDataModel.get(
            self.USER_ID, self.EXP_ID1)
        # Ruling out the possibility of None for mypy type checking.
        assert exp_user_data is not None
        self.assertEqual(exp_user_data.exploration_id, self.EXP_ID1)
        self.assertEqual(
            exp_user_data.draft_change_list, self.NEW_CHANGELIST_DICT)
        self.assertEqual(
            exp_user_data.draft_change_list_last_updated, self.NEWER_DATETIME)
        self.assertEqual(exp_user_data.draft_change_list_exp_version, 5)
        self.assertEqual(exp_user_data.draft_change_list_id, 3)

    def test_create_or_update_draft_when_newer_draft_exists(self) -> None:
        exp_services.create_or_update_draft(
            self.EXP_ID1, self.USER_ID, self.NEW_CHANGELIST, 5,
            self.OLDER_DATETIME)
        exp_user_data = user_models.ExplorationUserDataModel.get(
            self.USER_ID, self.EXP_ID1)
        # Ruling out the possibility of None for mypy type checking.
        assert exp_user_data is not None
        self.assertEqual(exp_user_data.exploration_id, self.EXP_ID1)
        self.assertEqual(
            exp_user_data.draft_change_list, self.draft_change_list_dict)
        self.assertEqual(
            exp_user_data.draft_change_list_last_updated, self.DATETIME)
        self.assertEqual(exp_user_data.draft_change_list_exp_version, 2)
        self.assertEqual(exp_user_data.draft_change_list_id, 2)

    def test_create_or_update_draft_when_draft_does_not_exist(self) -> None:
        exp_services.create_or_update_draft(
            self.EXP_ID3, self.USER_ID, self.NEW_CHANGELIST, 5,
            self.NEWER_DATETIME)
        exp_user_data = user_models.ExplorationUserDataModel.get(
            self.USER_ID, self.EXP_ID3)
        # Ruling out the possibility of None for mypy type checking.
        assert exp_user_data is not None
        self.assertEqual(exp_user_data.exploration_id, self.EXP_ID3)
        self.assertEqual(
            exp_user_data.draft_change_list, self.NEW_CHANGELIST_DICT)
        self.assertEqual(
            exp_user_data.draft_change_list_last_updated, self.NEWER_DATETIME)
        self.assertEqual(exp_user_data.draft_change_list_exp_version, 5)
        self.assertEqual(exp_user_data.draft_change_list_id, 1)

    def test_get_exp_with_draft_applied_when_draft_exists(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_ID1)
        self.assertEqual(exploration.init_state.param_changes, [])
        updated_exp = exp_services.get_exp_with_draft_applied(
            self.EXP_ID1, self.USER_ID)
        self.assertIsNotNone(updated_exp)
        # Ruling out the possibility of None for mypy type checking.
        assert updated_exp is not None
        param_changes = updated_exp.init_state.param_changes[0].to_dict()
        self.assertEqual(param_changes['name'], 'myParam')
        self.assertEqual(param_changes['generator_id'], 'RandomSelector')
        self.assertEqual(
            param_changes['customization_args'],
            {'list_of_values': ['1', '2'], 'parse_with_jinja': False})

    def test_get_exp_with_draft_applied_when_draft_does_not_exist(
        self
    ) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_ID3)
        self.assertEqual(exploration.init_state.param_changes, [])
        updated_exp = exp_services.get_exp_with_draft_applied(
            self.EXP_ID3, self.USER_ID)
        self.assertIsNone(updated_exp)

    def test_get_exp_with_draft_applied_when_draft_version_is_invalid(
        self
    ) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_ID2)
        self.assertEqual(exploration.init_state.param_changes, [])
        updated_exp = exp_services.get_exp_with_draft_applied(
            self.EXP_ID2, self.USER_ID)
        self.assertIsNone(updated_exp)

    def test_draft_discarded(self) -> None:
        user_data_model = (
            exp_services.get_exp_user_data_model_with_draft_discarded(
                self.EXP_ID1,
                self.USER_ID
            )
        )
        assert user_data_model is not None
        user_data_model.update_timestamps()
        user_data_model.put()
        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
            '%s.%s' % (self.USER_ID, self.EXP_ID1))
        self.assertIsNone(exp_user_data.draft_change_list)
        self.assertIsNone(exp_user_data.draft_change_list_last_updated)
        self.assertIsNone(exp_user_data.draft_change_list_exp_version)

    def test_create_or_update_draft_with_exploration_model_not_created(
        self
    ) -> None:
        self.save_new_valid_exploration(
            'exp_id', self.admin_id, title='title')

        rights_manager.assign_role_for_exploration(
            self.admin, 'exp_id', self.editor_id, rights_domain.ROLE_EDITOR)

        exp_user_data = user_models.ExplorationUserDataModel.get(
            self.editor_id, 'exp_id')
        self.assertIsNone(exp_user_data)

        exp_services.create_or_update_draft(
            'exp_id', self.editor_id, self.NEW_CHANGELIST, 1,
            self.NEWER_DATETIME)
        exp_user_data = user_models.ExplorationUserDataModel.get(
            self.editor_id, 'exp_id')
        # Ruling out the possibility of None for mypy type checking.
        assert exp_user_data is not None
        self.assertEqual(exp_user_data.exploration_id, 'exp_id')
        self.assertEqual(
            exp_user_data.draft_change_list, self.NEW_CHANGELIST_DICT)
        self.assertEqual(
            exp_user_data.draft_change_list_last_updated, self.NEWER_DATETIME)
        self.assertEqual(exp_user_data.draft_change_list_exp_version, 1)
        self.assertEqual(exp_user_data.draft_change_list_id, 1)

    def test_get_exp_with_draft_applied_when_draft_has_invalid_math_tags(
        self
    ) -> None:
        """Test the method get_exp_with_draft_applied when the draft_changes
        have invalid math-tags in them.
        """
        exploration = exp_domain.Exploration.create_default_exploration(
            'exp_id')
        exploration.add_states(['State1'])
        state = exploration.states['State1']
        choices_subtitled_html_dicts: List[state_domain.SubtitledHtmlDict] = [
            {
                'content_id': 'ca_choices_0',
                'html': '<p>state customization arg html 1</p>'
            },
            {
                'content_id': 'ca_choices_1',
                'html': '<p>state customization arg html 2</p>'
            },
            {
                'content_id': 'ca_choices_2',
                'html': '<p>state customization arg html 3</p>'
            },
            {
                'content_id': 'ca_choices_3',
                'html': '<p>state customization arg html 4</p>'
            }
        ]
        state_customization_args_dict: Dict[
            str, Dict[str, Union[int, List[state_domain.SubtitledHtmlDict]]]
        ] = {
            'choices': {
                'value': choices_subtitled_html_dicts
            },
            'maxAllowableSelectionCount': {
                'value': 1
            },
            'minAllowableSelectionCount': {
                'value': 1
            }
        }
        state.update_interaction_id('ItemSelectionInput')
        state.update_interaction_customization_args(
            state_customization_args_dict)
        exp_services.save_new_exploration(self.USER_ID, exploration)
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'state_name': 'State1',
            'property_name': 'widget_customization_args',
            'new_value': {
                'choices': {
                    'value': [
                        {
                            'content_id': 'ca_choices_0',
                            'html': '<p>1</p>'
                        },
                        {
                            'content_id': 'ca_choices_1',
                            'html': '<p>2</p>'
                        },
                        {
                            'content_id': 'ca_choices_2',
                            'html': (
                                '<oppia-noninteractive-math raw_latex-with'
                                '-value="&amp;quot;(x - a_1)(x - a_2)(x - a_3).'
                                '..(x - a_n)&amp;quot;"></oppia-noninteractive-'
                                'math>'
                            )
                        },
                        {
                            'content_id': 'ca_choices_3',
                            'html': '<p>4</p>'
                        }
                    ]
                },
                'maxAllowableSelectionCount': {
                    'value': 1
                },
                'minAllowableSelectionCount': {
                    'value': 1
                }
            }
        }).to_dict()]
        user_models.ExplorationUserDataModel(
            id='%s.%s' % (self.USER_ID, 'exp_id'), user_id=self.USER_ID,
            exploration_id='exp_id',
            draft_change_list=change_list,
            draft_change_list_last_updated=self.DATETIME,
            draft_change_list_exp_version=1,
            draft_change_list_id=2).put()
        with self.swap(state_domain.SubtitledHtml, 'validate', lambda x: True):
            updated_exploration = exp_services.get_exp_with_draft_applied(
                'exp_id', self.USER_ID)
        self.assertIsNone(updated_exploration)


class ApplyDraftUnitTests(test_utils.GenericTestBase):
    """Test apply draft functions in exp_services."""

    EXP_ID1: Final = 'exp_id1'
    USER_ID: Final = 'user_id'
    DATETIME: Final = datetime.datetime.strptime('2016-02-16', '%Y-%m-%d')

    def setUp(self) -> None:
        super().setUp()
        # Create explorations.
        exploration = self.save_new_valid_exploration(
            self.EXP_ID1, self.USER_ID)

        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'param_specs',
            'new_value': {
                'myParam': {'obj_type': 'UnicodeString'}
            }
        })]
        exp_services.update_exploration(
            self.USER_ID, self.EXP_ID1, change_list, '')

        migration_change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_MIGRATE_STATES_SCHEMA_TO_LATEST_VERSION,
            'from_version': 54,
            'to_version': str(feconf.CURRENT_STATE_SCHEMA_VERSION)
        })]
        exp_services.update_exploration(
            self.USER_ID, self.EXP_ID1,
            migration_change_list, 'Migrate state schema.')

        state = exploration.states[exploration.init_state_name]
        self.draft_change_list = _get_change_list(
            exploration.init_state_name, 'content', {
                'content_id': state.content.content_id,
                'html': '<p>New html value</p>'
            })
        self.draft_change_list_dict = [
            change.to_dict() for change in self.draft_change_list]
        # Explorations with draft set.
        exp_user_data = user_models.ExplorationUserDataModel.create(
            self.USER_ID, self.EXP_ID1)
        exp_user_data.draft_change_list = self.draft_change_list_dict
        exp_user_data.draft_change_list_last_updated = self.DATETIME
        exp_user_data.draft_change_list_exp_version = 2
        exp_user_data.draft_change_list_id = 2
        exp_user_data.update_timestamps()
        exp_user_data.put()

    def test_get_exp_with_draft_applied_after_draft_upgrade(self) -> None:
        exploration = exp_fetchers.get_exploration_by_id(self.EXP_ID1)
        self.assertEqual(exploration.init_state.param_changes, [])
        updated_exp = exp_services.get_exp_with_draft_applied(
            self.EXP_ID1, self.USER_ID)
        self.assertIsNotNone(updated_exp)
        # Ruling out the possibility of None for mypy type checking.
        assert updated_exp is not None
        new_content_dict = updated_exp.init_state.content.to_dict()
        self.assertEqual(new_content_dict['html'], '<p>New html value</p>')
        self.assertEqual(new_content_dict['content_id'], 'content_0')

    def test_get_exp_with_draft_applied_when_draft_has_exp_property_changes(
        self
    ) -> None:
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
            'property_name': 'title',
            'new_value': 'New title'
        }).to_dict()]
        user_models.ExplorationUserDataModel(
            id='%s.%s' % (self.USER_ID, self.EXP_ID1), user_id=self.USER_ID,
            exploration_id=self.EXP_ID1,
            draft_change_list=change_list,
            draft_change_list_last_updated=self.DATETIME,
            draft_change_list_exp_version=2,
            draft_change_list_id=2).put()
        updated_exploration = exp_services.get_exp_with_draft_applied(
            self.EXP_ID1, self.USER_ID)
        self.assertFalse(updated_exploration is None)


class UpdateVersionHistoryUnitTests(ExplorationServicesUnitTests):
    """Tests for ensuring creation, deletion and updation of version history
    data is carried out correctly.
    """

    def setUp(self) -> None:
        super().setUp()
        exploration = exp_domain.Exploration.create_default_exploration(
            self.EXP_0_ID)
        exp_services.save_new_exploration(self.owner_id, exploration)
        self.exploration = exploration
        self.version_history_model_class: Type[
            exp_models.ExplorationVersionHistoryModel
        ] = (
            exp_models.ExplorationVersionHistoryModel)

    def test_creating_new_exploration_creates_version_history_model(
        self
    ) -> None:
        version_history_id = (
            self.version_history_model_class.get_instance_id(
                self.exploration.id, self.exploration.version))
        version_history_model = self.version_history_model_class.get(
            version_history_id)
        expected_state_version_history_dict = {
            feconf.DEFAULT_INIT_STATE_NAME: state_domain.StateVersionHistory(
                None, None, self.owner_id
            ).to_dict()
        }

        self.assertEqual(
            version_history_model.state_version_history,
            expected_state_version_history_dict)
        self.assertEqual(
            version_history_model.metadata_last_edited_version_number, None)
        self.assertEqual(
            version_history_model.metadata_last_edited_committer_id,
            self.owner_id)
        self.assertIn(self.owner_id, version_history_model.committer_ids)

    def test_soft_deletion_does_not_delete_version_history_models(self) -> None:
        version_history_models_before_deletion: Sequence[
            exp_models.ExplorationVersionHistoryModel
        ] = (
            self.version_history_model_class.query(
                self.version_history_model_class.exploration_id ==
                    self.exploration.id
            ).fetch())
        exp_services.delete_exploration(self.owner_id, self.exploration.id)
        version_history_models_after_deletion: Sequence[
            exp_models.ExplorationVersionHistoryModel
        ] = (
            self.version_history_model_class.query(
                self.version_history_model_class.exploration_id ==
                    self.exploration.id
            ).fetch())

        self.assertEqual(
            version_history_models_before_deletion,
            version_history_models_after_deletion)

    def test_hard_deletion_deletes_version_history_models(self) -> None:
        version_history_models_before_deletion: Sequence[
            exp_models.ExplorationVersionHistoryModel
        ] = (
            self.version_history_model_class.query(
                self.version_history_model_class.exploration_id ==
                    self.exploration.id
            ).fetch())
        exp_services.delete_exploration(
            self.owner_id, self.exploration.id, force_deletion=True)
        version_history_models_after_deletion: Sequence[
            exp_models.ExplorationVersionHistoryModel
        ] = (
            self.version_history_model_class.query(
                self.version_history_model_class.exploration_id ==
                    self.exploration.id
            ).fetch())

        self.assertNotEqual(
            version_history_models_before_deletion,
            version_history_models_after_deletion)

    def test_version_history_on_add_state(self) -> None:
        old_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 1))

        self.assertEqual(
            old_model.state_version_history.get('New state'), None)
        content_id_generator = translation_domain.ContentIdGenerator(
            self.exploration.next_content_id_index)
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'New state',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }),
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index,
                'old_value': 0
            })], 'Added state')

        new_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 2))

        self.assertEqual(
            new_model.state_version_history.get('New state'),
            state_domain.StateVersionHistory(
                None, None, self.owner_id).to_dict())

    def test_version_history_on_delete_state(self) -> None:
        content_id_generator: translation_domain.ContentIdGenerator = (
            translation_domain.ContentIdGenerator(
                self.exploration.next_content_id_index))
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'New state',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }),
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index,
                'old_value': 0
            })], 'Added state')
        old_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 2))

        self.assertEqual(
            old_model.state_version_history.get('New state'),
            state_domain.StateVersionHistory(
                None, None, self.owner_id).to_dict())

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_DELETE_STATE,
                'state_name': 'New state',
            })], 'Deleted state')
        new_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 3))

        self.assertEqual(
            new_model.state_version_history.get('New state'), None)

    def test_version_history_on_rename_state(self) -> None:
        old_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 1))
        new_state_name = 'Another name'

        self.assertEqual(
            old_model.state_version_history.get(
                feconf.DEFAULT_INIT_STATE_NAME),
            state_domain.StateVersionHistory(
                None, None, self.owner_id).to_dict())
        self.assertEqual(
            old_model.state_version_history.get(new_state_name), None)

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_RENAME_STATE,
                'old_state_name': feconf.DEFAULT_INIT_STATE_NAME,
                'new_state_name': new_state_name
            })], 'Renamed state')

        new_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 2))

        self.assertEqual(
            new_model.state_version_history.get(
                feconf.DEFAULT_INIT_STATE_NAME), None)
        self.assertEqual(
            new_model.state_version_history.get(new_state_name),
            state_domain.StateVersionHistory(
                1, feconf.DEFAULT_INIT_STATE_NAME, self.owner_id).to_dict())

    def test_version_history_on_cancelled_rename_state(self) -> None:
        old_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 1))
        new_state_name = 'Another name'
        expected_dict = state_domain.StateVersionHistory(
            None, None, self.owner_id).to_dict()

        self.assertEqual(
            old_model.state_version_history.get(
                feconf.DEFAULT_INIT_STATE_NAME), expected_dict)

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_RENAME_STATE,
                    'old_state_name': feconf.DEFAULT_INIT_STATE_NAME,
                    'new_state_name': new_state_name
                }), exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_RENAME_STATE,
                    'old_state_name': new_state_name,
                    'new_state_name': feconf.DEFAULT_INIT_STATE_NAME
                })
            ], 'Renamed state')

        new_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 2))

        self.assertEqual(
            new_model.state_version_history.get(
                feconf.DEFAULT_INIT_STATE_NAME), expected_dict)

    def test_version_history_on_edit_state_property(self) -> None:
        old_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 1))

        self.assertEqual(
            old_model.state_version_history.get(
                feconf.DEFAULT_INIT_STATE_NAME),
            state_domain.StateVersionHistory(
                None, None, self.owner_id).to_dict())

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
                    'state_name': feconf.DEFAULT_INIT_STATE_NAME,
                    'new_value': 'TextInput'
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name':
                        exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
                    'state_name': feconf.DEFAULT_INIT_STATE_NAME,
                    'new_value': {
                        'placeholder': {
                            'value': {
                                'content_id': 'ca_placeholder_0',
                                'unicode_str': ''
                            }
                        },
                        'rows': {'value': 1},
                        'catchMisspellings': {'value': False}
                    }
                })
            ], 'Edited interaction'
        )

        new_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 2))

        self.assertEqual(
            new_model.state_version_history.get(
                feconf.DEFAULT_INIT_STATE_NAME),
            state_domain.StateVersionHistory(
                1, feconf.DEFAULT_INIT_STATE_NAME, self.owner_id).to_dict())

    def test_version_history_on_cancelled_edit_state_property(self) -> None:
        old_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 1))
        expected_dict = state_domain.StateVersionHistory(
            None, None, self.owner_id).to_dict()

        self.assertEqual(
            old_model.state_version_history.get(
                feconf.DEFAULT_INIT_STATE_NAME), expected_dict)

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
                    'state_name': feconf.DEFAULT_INIT_STATE_NAME,
                    'new_value': 'TextInput'
                }),
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
                    'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
                    'state_name': feconf.DEFAULT_INIT_STATE_NAME,
                    'new_value': None
                })
            ], 'Edited interaction id'
        )

        new_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 2))

        self.assertEqual(
            new_model.state_version_history.get(
                feconf.DEFAULT_INIT_STATE_NAME), expected_dict)

    def test_version_history_on_only_translation_commits(self) -> None:
        old_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 1))
        expected_dict = state_domain.StateVersionHistory(
            None, None, self.owner_id).to_dict()

        self.assertEqual(
            old_model.state_version_history.get(
                feconf.DEFAULT_INIT_STATE_NAME), expected_dict)

        recorded_voiceovers_dict = {
            'voiceovers_mapping': {
                'content_0': {
                    'en': {
                        'filename': 'filename3.mp3',
                        'file_size_bytes': 3000,
                        'needs_update': False,
                        'duration_secs': 42.43
                    }
                },
                'default_outcome_1': {}
            }
        }
        change_list = [exp_domain.ExplorationChange({
            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
            'property_name': (
                exp_domain.STATE_PROPERTY_RECORDED_VOICEOVERS),
            'state_name': feconf.DEFAULT_INIT_STATE_NAME,
            'new_value': recorded_voiceovers_dict
        })]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list, 'Translation commits')

        new_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 2))

        self.assertEqual(
            new_model.state_version_history.get(
                feconf.DEFAULT_INIT_STATE_NAME), expected_dict)

    def test_version_history_on_edit_exploration_property(self) -> None:
        old_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 1))

        self.assertEqual(old_model.metadata_last_edited_version_number, None)
        self.assertEqual(
            old_model.metadata_last_edited_committer_id, self.owner_id)

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
              'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
              'property_name': 'title',
              'new_value': 'New title'})], 'Changed title')

        new_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 2))

        self.assertEqual(new_model.metadata_last_edited_version_number, 1)
        self.assertEqual(
            new_model.metadata_last_edited_committer_id, self.owner_id)

    def test_version_history_on_cancelled_edit_exploration_property(
        self
    ) -> None:
        old_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 1))

        self.assertEqual(old_model.metadata_last_edited_version_number, None)
        self.assertEqual(
            old_model.metadata_last_edited_committer_id, self.owner_id)

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                    'property_name': 'title',
                    'new_value': 'New title'}
                ), exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                    'property_name': 'title',
                    'new_value': feconf.DEFAULT_EXPLORATION_TITLE}
                )
            ], 'Changed title')

        new_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 2))

        self.assertEqual(new_model.metadata_last_edited_version_number, None)
        self.assertEqual(
            new_model.metadata_last_edited_committer_id, self.owner_id)

    def test_version_history_on_revert_exploration(self) -> None:
        old_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 1))

        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
              'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
              'property_name': 'title',
              'new_value': 'New title'})], 'Changed title')
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [
                exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_RENAME_STATE,
                    'old_state_name': feconf.DEFAULT_INIT_STATE_NAME,
                    'new_state_name': 'Another state'
                })
            ], 'Renamed state')
        exp_services.revert_exploration(self.owner_id, self.EXP_0_ID, 3, 1)

        new_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 4))

        self.assertEqual(
            old_model.state_version_history,
            new_model.state_version_history)
        self.assertEqual(
            old_model.metadata_last_edited_version_number,
            new_model.metadata_last_edited_version_number)
        self.assertEqual(
            old_model.metadata_last_edited_committer_id,
            new_model.metadata_last_edited_committer_id)
        self.assertEqual(old_model.committer_ids, new_model.committer_ids)

    def test_version_history_on_cancelled_add_state(self) -> None:
        # In this case, the version history for that state should not be
        # recorded because it was added and deleted in the same commit.
        old_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 1))
        content_id_generator = translation_domain.ContentIdGenerator(
            self.exploration.next_content_id_index)
        change_list = [
          exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'New state',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
          }), exp_domain.ExplorationChange({
              'cmd': exp_domain.CMD_DELETE_STATE,
              'state_name': 'New state'
          })
        ]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list,
            'Added and deleted state')
        new_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 2))

        self.assertIsNone(old_model.state_version_history.get('New state'))
        self.assertIsNone(new_model.state_version_history.get('New state'))

    def test_version_history_on_state_name_interchange(self) -> None:
        content_id_generator = translation_domain.ContentIdGenerator(
            self.exploration.next_content_id_index)
        change_list_from_v1_to_v2 = [
            exp_domain.ExplorationChange({
                    'cmd': exp_domain.CMD_ADD_STATE,
                    'state_name': 'first',
                    'content_id_for_state_content': (
                        content_id_generator.generate(
                            translation_domain.ContentType.CONTENT)
                    ),
                    'content_id_for_default_outcome': (
                        content_id_generator.generate(
                            translation_domain.ContentType.DEFAULT_OUTCOME)
                    )
            }), exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'second',
                'content_id_for_state_content': (
                        content_id_generator.generate(
                            translation_domain.ContentType.CONTENT)
                    ),
                    'content_id_for_default_outcome': (
                        content_id_generator.generate(
                            translation_domain.ContentType.DEFAULT_OUTCOME)
                    )
            }), exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index,
                'old_value': 0
            })
        ]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list_from_v1_to_v2,
            'Added two new states')
        old_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 2))

        self.assertEqual(
            old_model.state_version_history['first'],
            state_domain.StateVersionHistory(
                None, None, self.owner_id).to_dict())
        self.assertEqual(
            old_model.state_version_history['second'],
            state_domain.StateVersionHistory(
                None, None, self.owner_id).to_dict())

        # Correctly interchanging the state names.
        change_list_from_v2_to_v3 = [
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_RENAME_STATE,
                'old_state_name': 'first',
                'new_state_name': 'temporary'
            }), exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_RENAME_STATE,
                'old_state_name': 'second',
                'new_state_name': 'first'
            }), exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_RENAME_STATE,
                'old_state_name': 'temporary',
                'new_state_name': 'second'
            })
        ]
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, change_list_from_v2_to_v3,
            'Added two new states')
        new_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 3))

        self.assertEqual(
            new_model.state_version_history['second'],
            state_domain.StateVersionHistory(
                2, 'first', self.owner_id).to_dict())
        self.assertEqual(
            new_model.state_version_history['first'],
            state_domain.StateVersionHistory(
                2, 'second', self.owner_id).to_dict())

    def test_new_committer_id_is_added_to_committer_ids_list(self) -> None:
        old_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 1))

        self.assertNotIn(self.editor_id, old_model.committer_ids)

        content_id_generator = translation_domain.ContentIdGenerator(
            self.exploration.next_content_id_index)
        exp_services.update_exploration(
            self.editor_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'New state',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }),
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index,
                'old_value': 0
            })], 'Added a state')
        exp_services.update_exploration(
            self.owner_id, self.EXP_0_ID, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_ADD_STATE,
                'state_name': 'Another state',
                'content_id_for_state_content': (
                    content_id_generator.generate(
                        translation_domain.ContentType.CONTENT)
                ),
                'content_id_for_default_outcome': (
                    content_id_generator.generate(
                        translation_domain.ContentType.DEFAULT_OUTCOME)
                )
            }),
            exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'next_content_id_index',
                'new_value': content_id_generator.next_content_id_index,
                'old_value': 0
            })], 'Added a state')
        new_model = self.version_history_model_class.get(
            self.version_history_model_class.get_instance_id(self.EXP_0_ID, 3))

        self.assertIn(self.editor_id, new_model.committer_ids)


class LoggedOutUserProgressUpdateTests(test_utils.GenericTestBase):
    """Tests whether logged-out user progress is updated correctly"""

    EXP_ID: Final = 'exp_id0'
    UNIQUE_PROGRESS_URL_ID: Final = 'pid123'

    SAMPLE_EXPLORATION_YAML: str = (
"""
author_notes: ''
auto_tts_enabled: true
blurb: ''
category: Category
edits_allowed: true
init_state_name: Introduction
language_code: en
objective: ''
param_changes: []
param_specs: {}
schema_version: 47
states:
  Introduction:
    classifier_model_id: null
    content:
      content_id: content
      html: ''
    interaction:
      answer_groups:
      - outcome:
          dest: New state
          feedback:
            content_id: feedback_1
            html: <p>Correct!</p>
          labelled_as_correct: false
          missing_prerequisite_skill_id: null
          param_changes: []
          refresher_exploration_id: null
        rule_specs:
        - inputs:
            x:
              contentId: rule_input_3
              normalizedStrSet:
              - InputString
          rule_type: Equals
        tagged_skill_misconception_id: null
        training_data: []
      confirmed_unclassified_answers: []
      customization_args:
        placeholder:
          value:
            content_id: ca_placeholder_2
            unicode_str: ''
        rows:
          value: 1
        catchMisspellings:
          value: false
      default_outcome:
        dest: Introduction
        feedback:
          content_id: default_outcome
          html: ''
        labelled_as_correct: false
        missing_prerequisite_skill_id: null
        param_changes: []
        refresher_exploration_id: null
      hints:
      - hint_content:
          content_id: hint_1
          html: <p>hint one,</p>
      id: TextInput
      solution:
        answer_is_exclusive: false
        correct_answer: helloworld!
        explanation:
          content_id: solution
          html: <p>hello_world is a string</p>
    linked_skill_id: null
    next_content_id_index: 4
    param_changes: []
    recorded_voiceovers:
      voiceovers_mapping:
        ca_placeholder_2: {}
        content:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: introduction_state.mp3
            needs_update: false
        default_outcome:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: unknown_answer_feedback.mp3
            needs_update: false
        feedback_1:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: correct_answer_feedback.mp3
            needs_update: false
        hint_1:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: answer_hint.mp3
            needs_update: false
        rule_input_3: {}
        solution:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: answer_solution.mp3
            needs_update: false
    solicit_answer_details: false
    card_is_checkpoint: true
    written_translations:
      translations_mapping:
        ca_placeholder_2: {}
        content: {}
        default_outcome: {}
        feedback_1: {}
        hint_1: {}
        rule_input_3: {}
        solution: {}
  New state:
    classifier_model_id: null
    content:
      content_id: content
      html: ''
    interaction:
      answer_groups: []
      confirmed_unclassified_answers: []
      customization_args: {}
      default_outcome:
        dest: New state
        feedback:
          content_id: default_outcome
          html: ''
        labelled_as_correct: false
        missing_prerequisite_skill_id: null
        param_changes: []
        refresher_exploration_id: null
      hints: []
      id: null
      solution: null
    linked_skill_id: null
    next_content_id_index: 0
    param_changes: []
    recorded_voiceovers:
      voiceovers_mapping:
        content: {}
        default_outcome: {}
    solicit_answer_details: false
    card_is_checkpoint: false
    written_translations:
      translations_mapping:
        content: {}
        default_outcome: {}
states_schema_version: 42
tags: []
title: Title
""")

    def setUp(self) -> None:
        super().setUp()
        self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME)
        self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)

        exp_services.save_new_exploration_from_yaml_and_assets(
            self.owner_id, self.SAMPLE_EXPLORATION_YAML, self.EXP_ID, [])
        self.exploration = exp_fetchers.get_exploration_by_id(self.EXP_ID)

    def test_logged_out_user_checkpoint_progress_is_updated_correctly(
        self
    ) -> None:
        logged_out_user_data = exp_fetchers.get_logged_out_user_progress(
            self.UNIQUE_PROGRESS_URL_ID
        )
        self.assertIsNone(logged_out_user_data)

        # First checkpoint reached.
        exp_services.update_logged_out_user_progress(
            self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID, 'Introduction', 1)
        logged_out_user_data = exp_fetchers.get_logged_out_user_progress(
            self.UNIQUE_PROGRESS_URL_ID)
        # Ruling out the possibility of None for mypy type checking.
        assert logged_out_user_data is not None
        self.assertEqual(
            logged_out_user_data.furthest_reached_checkpoint_exp_version, 1)
        self.assertEqual(
            logged_out_user_data.furthest_reached_checkpoint_state_name,
            'Introduction')
        self.assertEqual(
            logged_out_user_data.
                most_recently_reached_checkpoint_exp_version, 1)
        self.assertEqual(
            logged_out_user_data.
                most_recently_reached_checkpoint_state_name, 'Introduction')

        # Make 'New state' a checkpoint.
        # Now version of the exploration becomes 2.
        change_list = _get_change_list(
            'New state',
            exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT,
            True)
        exp_services.update_exploration(
            self.owner_id, self.EXP_ID, change_list, '')

        # Second checkpoint reached.
        exp_services.update_logged_out_user_progress(
            self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID, 'New state', 2)
        logged_out_user_data = exp_fetchers.get_logged_out_user_progress(
            self.UNIQUE_PROGRESS_URL_ID)
        # Ruling out the possibility of None for mypy type checking.
        assert logged_out_user_data is not None
        self.assertEqual(
            logged_out_user_data.furthest_reached_checkpoint_exp_version, 2)
        self.assertEqual(
            logged_out_user_data.furthest_reached_checkpoint_state_name,
            'New state')
        self.assertEqual(
            logged_out_user_data.most_recently_reached_checkpoint_exp_version,
            2)
        self.assertEqual(
            logged_out_user_data.most_recently_reached_checkpoint_state_name,
            'New state')

        # Unmark 'New state' as a checkpoint.
        # Now version of the exploration becomes 3.
        change_list = _get_change_list(
            'New state',
            exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT,
            False)
        exp_services.update_exploration(
            self.owner_id, self.EXP_ID, change_list, '')

        # First checkpoint reached again.
        # Since the previously furthest reached checkpoint 'New state' doesn't
        # exist in the current exploration, the first checkpoint behind
        # 'New state' that exists in current exploration ('Introduction'
        # state in this case) becomes the new furthest reached checkpoint.
        exp_services.update_logged_out_user_progress(
            self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID, 'Introduction', 3)
        logged_out_user_data = exp_fetchers.get_logged_out_user_progress(
            self.UNIQUE_PROGRESS_URL_ID)
        # Ruling out the possibility of None for mypy type checking.
        assert logged_out_user_data is not None
        self.assertEqual(
            logged_out_user_data.furthest_reached_checkpoint_exp_version, 3)
        self.assertEqual(
            logged_out_user_data.furthest_reached_checkpoint_state_name,
            'Introduction')
        self.assertEqual(
            logged_out_user_data.most_recently_reached_checkpoint_exp_version,
            3)
        self.assertEqual(
            logged_out_user_data.most_recently_reached_checkpoint_state_name,
            'Introduction')

        # Change state name of 'Introduction' state.
        # Now version of exploration becomes 4.
        exp_services.update_exploration(
            self.owner_id, self.EXP_ID,
            [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_RENAME_STATE,
                'old_state_name': 'Introduction',
                'new_state_name': 'Intro',
            })], 'Change state name'
        )

        # First checkpoint reached again.
        exp_services.update_logged_out_user_progress(
            self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID, 'Intro', 4)
        logged_out_user_data = exp_fetchers.get_logged_out_user_progress(
            self.UNIQUE_PROGRESS_URL_ID)
        # Ruling out the possibility of None for mypy type checking.
        assert logged_out_user_data is not None
        self.assertEqual(
            logged_out_user_data.furthest_reached_checkpoint_exp_version, 4)
        self.assertEqual(
            logged_out_user_data.furthest_reached_checkpoint_state_name,
            'Intro')
        self.assertEqual(
            logged_out_user_data.most_recently_reached_checkpoint_exp_version,
            4)
        self.assertEqual(
            logged_out_user_data.most_recently_reached_checkpoint_state_name,
            'Intro')

    def test_sync_logged_out_learner_checkpoint_progress_with_current_exp_version(  # pylint: disable=line-too-long
        self
    ) -> None:
        logged_out_user_data = (
            exp_services.sync_logged_out_learner_checkpoint_progress_with_current_exp_version( # pylint: disable=line-too-long
                self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID))
        self.assertIsNone(logged_out_user_data)

        # First checkpoint reached.
        exp_services.update_logged_out_user_progress(
            self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID, 'Introduction', 1)
        logged_out_user_data = exp_fetchers.get_logged_out_user_progress(
            self.UNIQUE_PROGRESS_URL_ID)
        # Ruling out the possibility of None for mypy type checking.
        assert logged_out_user_data is not None
        self.assertEqual(
            logged_out_user_data.furthest_reached_checkpoint_exp_version, 1)
        self.assertEqual(
            logged_out_user_data.furthest_reached_checkpoint_state_name,
            'Introduction')
        self.assertEqual(
            logged_out_user_data.most_recently_reached_checkpoint_exp_version,
            1)
        self.assertEqual(
            logged_out_user_data.most_recently_reached_checkpoint_state_name,
            'Introduction')

        # Change state name of 'Introduction' state.
        # Now version of exploration becomes 2.
        exp_services.update_exploration(
            self.owner_id, self.EXP_ID,
            [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_RENAME_STATE,
                'old_state_name': 'Introduction',
                'new_state_name': 'Intro',
            })], 'Change state name'
        )

        # This method is called when exploration data is fetched since now
        # latest exploration version > most recently interacted exploration
        # version.
        # Working - First the furthest reached checkpoint ('Introduction' in
        # this case) is searched in current exploration. It will not be found
        # since its state name is changed to 'Intro'. It will then search for
        # an checkpoint that had been reached in older exploration and also
        # exists in current exploration. If such checkpoint is not found,
        # furthest reached checkpoint is set to None. Similar workflow is
        # carried out for most recently reached checkpoint.
        logged_out_user_data = (
            exp_services.sync_logged_out_learner_checkpoint_progress_with_current_exp_version( # pylint: disable=line-too-long
                self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID))
        # Ruling out the possibility of None for mypy type checking.
        assert logged_out_user_data is not None
        self.assertEqual(
            logged_out_user_data.furthest_reached_checkpoint_exp_version, 2)
        self.assertIsNone(
            logged_out_user_data.furthest_reached_checkpoint_state_name)
        self.assertEqual(
            logged_out_user_data.most_recently_reached_checkpoint_exp_version,
            2)
        self.assertIsNone(
            logged_out_user_data.most_recently_reached_checkpoint_state_name)


class SyncLoggedInAndLoggedOutProgressTests(test_utils.GenericTestBase):
    """Tests whether logged-in user progress is synced correctly"""

    EXP_ID: Final = 'exp_id0'
    UNIQUE_PROGRESS_URL_ID: Final = 'pid123'

    SAMPLE_EXPLORATION_YAML: str = (
"""
author_notes: ''
auto_tts_enabled: true
blurb: ''
category: Category
edits_allowed: true
init_state_name: Introduction
language_code: en
objective: ''
param_changes: []
param_specs: {}
schema_version: 47
states:
  Introduction:
    classifier_model_id: null
    content:
      content_id: content
      html: ''
    interaction:
      answer_groups:
      - outcome:
          dest: New state
          feedback:
            content_id: feedback_1
            html: <p>Correct!</p>
          labelled_as_correct: false
          missing_prerequisite_skill_id: null
          param_changes: []
          refresher_exploration_id: null
        rule_specs:
        - inputs:
            x:
              contentId: rule_input_3
              normalizedStrSet:
              - InputString
          rule_type: Equals
        tagged_skill_misconception_id: null
        training_data: []
      confirmed_unclassified_answers: []
      customization_args:
        placeholder:
          value:
            content_id: ca_placeholder_2
            unicode_str: ''
        rows:
          value: 1
        catchMisspellings:
          value: false
      default_outcome:
        dest: Introduction
        feedback:
          content_id: default_outcome
          html: ''
        labelled_as_correct: false
        missing_prerequisite_skill_id: null
        param_changes: []
        refresher_exploration_id: null
      hints:
      - hint_content:
          content_id: hint_1
          html: <p>hint one,</p>
      id: TextInput
      solution:
        answer_is_exclusive: false
        correct_answer: helloworld!
        explanation:
          content_id: solution
          html: <p>hello_world is a string</p>
    linked_skill_id: null
    next_content_id_index: 4
    param_changes: []
    recorded_voiceovers:
      voiceovers_mapping:
        ca_placeholder_2: {}
        content:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: introduction_state.mp3
            needs_update: false
        default_outcome:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: unknown_answer_feedback.mp3
            needs_update: false
        feedback_1:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: correct_answer_feedback.mp3
            needs_update: false
        hint_1:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: answer_hint.mp3
            needs_update: false
        rule_input_3: {}
        solution:
          en:
            duration_secs: 0.0
            file_size_bytes: 99999
            filename: answer_solution.mp3
            needs_update: false
    solicit_answer_details: false
    card_is_checkpoint: true
    written_translations:
      translations_mapping:
        ca_placeholder_2: {}
        content: {}
        default_outcome: {}
        feedback_1: {}
        hint_1: {}
        rule_input_3: {}
        solution: {}
  New state:
    classifier_model_id: null
    content:
      content_id: content
      html: ''
    interaction:
      answer_groups: []
      confirmed_unclassified_answers: []
      customization_args: {}
      default_outcome:
        dest: Third state
        feedback:
          content_id: default_outcome
          html: ''
        labelled_as_correct: false
        missing_prerequisite_skill_id: null
        param_changes: []
        refresher_exploration_id: null
      hints: []
      id: null
      solution: null
    linked_skill_id: null
    next_content_id_index: 0
    param_changes: []
    recorded_voiceovers:
      voiceovers_mapping:
        content: {}
        default_outcome: {}
    solicit_answer_details: false
    card_is_checkpoint: false
    written_translations:
      translations_mapping:
        content: {}
        default_outcome: {}
  Third state:
    classifier_model_id: null
    content:
      content_id: content
      html: ''
    interaction:
      answer_groups: []
      confirmed_unclassified_answers: []
      customization_args: {}
      default_outcome:
        dest: Third state
        feedback:
          content_id: default_outcome
          html: ''
        labelled_as_correct: false
        missing_prerequisite_skill_id: null
        param_changes: []
        refresher_exploration_id: null
      hints: []
      id: null
      solution: null
    linked_skill_id: null
    next_content_id_index: 0
    param_changes: []
    recorded_voiceovers:
      voiceovers_mapping:
        content: {}
        default_outcome: {}
    solicit_answer_details: false
    card_is_checkpoint: false
    written_translations:
      translations_mapping:
        content: {}
        default_outcome: {}
states_schema_version: 42
tags: []
title: Title
""")

    def setUp(self) -> None:
        super().setUp()
        self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME)
        self.signup(self.VIEWER_EMAIL, self.VIEWER_USERNAME)
        self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)
        self.viewer_id = self.get_user_id_from_email(self.VIEWER_EMAIL)

        exp_services.save_new_exploration_from_yaml_and_assets(
            self.owner_id, self.SAMPLE_EXPLORATION_YAML, self.EXP_ID, [])
        self.exploration = exp_fetchers.get_exploration_by_id(self.EXP_ID)

    def test_logged_in_user_progress_is_updated_correctly(self) -> None:
        self.login(self.VIEWER_EMAIL)
        exp_user_data = exp_fetchers.get_exploration_user_data(
            self.viewer_id, self.EXP_ID)
        self.assertIsNone(exp_user_data)

        logged_out_user_data = exp_fetchers.get_logged_out_user_progress(
            self.UNIQUE_PROGRESS_URL_ID
        )
        self.assertIsNone(logged_out_user_data)

        # No sync occurs if there is no logged-out user data or if the data
        # has been cleared by the cron job.
        exp_services.sync_logged_out_learner_progress_with_logged_in_progress(
            self.viewer_id, self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID
        )
        exp_user_data = exp_fetchers.get_exploration_user_data(
            self.viewer_id, self.EXP_ID)
        self.assertIsNone(exp_user_data)

        # First checkpoint reached as logged out user.
        exp_services.update_logged_out_user_progress(
            self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID, 'Introduction', 1)

        logged_out_user_data = exp_fetchers.get_logged_out_user_progress(
            self.UNIQUE_PROGRESS_URL_ID)
        # Ruling out the possibility of None for mypy type checking.
        assert logged_out_user_data is not None
        self.assertEqual(
            logged_out_user_data.furthest_reached_checkpoint_exp_version, 1)
        self.assertEqual(
            logged_out_user_data.furthest_reached_checkpoint_state_name,
            'Introduction')
        self.assertEqual(
            logged_out_user_data.
                most_recently_reached_checkpoint_exp_version, 1)
        self.assertEqual(
            logged_out_user_data.
                most_recently_reached_checkpoint_state_name, 'Introduction')

        exp_services.sync_logged_out_learner_progress_with_logged_in_progress(
            self.viewer_id, self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID
        )

        exp_user_data = exp_fetchers.get_exploration_user_data(
            self.viewer_id, self.EXP_ID)
        self.assertIsNotNone(exp_user_data)

        # Ruling out the possibility of None for mypy type checking.
        assert exp_user_data is not None
        assert logged_out_user_data is not None

        self.assertEqual(
            exp_user_data.most_recently_reached_checkpoint_exp_version,
            logged_out_user_data.most_recently_reached_checkpoint_exp_version
        )
        self.assertEqual(
            exp_user_data.most_recently_reached_checkpoint_state_name,
            logged_out_user_data.most_recently_reached_checkpoint_state_name
        )
        self.assertEqual(
            exp_user_data.furthest_reached_checkpoint_exp_version,
            logged_out_user_data.furthest_reached_checkpoint_exp_version
        )
        self.assertEqual(
            exp_user_data.furthest_reached_checkpoint_state_name,
            logged_out_user_data.furthest_reached_checkpoint_state_name
        )

        # Mark 'New state' as a checkpoint.
        # Now version of the exploration becomes 2.
        change_list = _get_change_list(
            'New state',
            exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT,
            True)
        exp_services.update_exploration(
            self.owner_id, self.EXP_ID, change_list, '')

        # New second checkpoint reached as logged out user.
        exp_services.update_logged_out_user_progress(
            self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID, 'New state', 2)

        exp_services.sync_logged_out_learner_progress_with_logged_in_progress(
            self.viewer_id, self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID
        )

        logged_out_user_data = exp_fetchers.get_logged_out_user_progress(
            self.UNIQUE_PROGRESS_URL_ID)

        exp_user_data = exp_fetchers.get_exploration_user_data(
            self.viewer_id, self.EXP_ID)

        # Ruling out the possibility of None for mypy type checking.
        assert exp_user_data is not None
        assert logged_out_user_data is not None

        self.assertEqual(
            exp_user_data.most_recently_reached_checkpoint_exp_version,
            logged_out_user_data.most_recently_reached_checkpoint_exp_version
        )
        self.assertEqual(
            exp_user_data.most_recently_reached_checkpoint_state_name,
            logged_out_user_data.most_recently_reached_checkpoint_state_name
        )
        self.assertEqual(
            exp_user_data.furthest_reached_checkpoint_exp_version,
            logged_out_user_data.furthest_reached_checkpoint_exp_version
        )
        self.assertEqual(
            exp_user_data.furthest_reached_checkpoint_state_name,
            logged_out_user_data.furthest_reached_checkpoint_state_name
        )

        # Mark 'Third state' as a checkpoint.
        # Now version of the exploration becomes 3.
        change_list = _get_change_list(
            'Third state',
            exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT,
            True)
        exp_services.update_exploration(
            self.owner_id, self.EXP_ID, change_list, '')

        # Unmark 'Next state' as a checkpoint.
        # Now version of the exploration becomes 4.
        change_list = _get_change_list(
            'New state',
            exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT,
            False)
        exp_services.update_exploration(
            self.owner_id, self.EXP_ID, change_list, '')

        # New third checkpoint reached as logged out user.
        exp_services.update_logged_out_user_progress(
            self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID, 'Third state', 4)

        exp_services.sync_logged_out_learner_progress_with_logged_in_progress(
            self.viewer_id, self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID
        )

        logged_out_user_data = exp_fetchers.get_logged_out_user_progress(
            self.UNIQUE_PROGRESS_URL_ID)

        exp_user_data = exp_fetchers.get_exploration_user_data(
            self.viewer_id, self.EXP_ID)

        # Ruling out the possibility of None for mypy type checking.
        assert exp_user_data is not None
        assert logged_out_user_data is not None

        self.assertEqual(
            exp_user_data.most_recently_reached_checkpoint_exp_version,
            logged_out_user_data.most_recently_reached_checkpoint_exp_version
        )
        self.assertEqual(
            exp_user_data.most_recently_reached_checkpoint_state_name,
            logged_out_user_data.most_recently_reached_checkpoint_state_name
        )
        self.assertEqual(
            exp_user_data.furthest_reached_checkpoint_exp_version,
            logged_out_user_data.furthest_reached_checkpoint_exp_version
        )
        self.assertEqual(
            exp_user_data.furthest_reached_checkpoint_state_name,
            logged_out_user_data.furthest_reached_checkpoint_state_name
        )

        # Changing logged-in most recently reached state.
        user_services.update_learner_checkpoint_progress(
            self.viewer_id,
            self.EXP_ID,
            'Introduction',
            4
        )

        exp_services.sync_logged_out_learner_progress_with_logged_in_progress(
            self.viewer_id, self.EXP_ID, self.UNIQUE_PROGRESS_URL_ID
        )

        logged_out_user_data = exp_fetchers.get_logged_out_user_progress(
            self.UNIQUE_PROGRESS_URL_ID)

        exp_user_data = exp_fetchers.get_exploration_user_data(
            self.viewer_id, self.EXP_ID)

        # Ruling out the possibility of None for mypy type checking.
        assert exp_user_data is not None
        assert logged_out_user_data is not None

        self.assertEqual(
            exp_user_data.most_recently_reached_checkpoint_exp_version,
            logged_out_user_data.most_recently_reached_checkpoint_exp_version
        )
        self.assertEqual(
            exp_user_data.most_recently_reached_checkpoint_state_name,
            logged_out_user_data.most_recently_reached_checkpoint_state_name
        )
        self.assertEqual(
            exp_user_data.furthest_reached_checkpoint_exp_version,
            logged_out_user_data.furthest_reached_checkpoint_exp_version
        )
        self.assertEqual(
            exp_user_data.furthest_reached_checkpoint_state_name,
            logged_out_user_data.furthest_reached_checkpoint_state_name
        )

        self.logout()


class RegenerateMissingExpStatsUnitTests(test_utils.GenericTestBase):
    """Test apply draft functions in exp_services."""

    def test_when_exp_and_state_stats_models_exist(self) -> None:
        self.save_new_default_exploration('ID', 'owner_id')

        self.assertEqual(
            exp_services.regenerate_missing_stats_for_exploration('ID'), (
                [], [], 1, 1))

    def test_fail_to_fetch_exploration_snapshots(self) -> None:
        observed_log_messages = []
        def _mock_logging_function(msg: str, *args: str) -> None:
            """Mocks logging.error()."""
            observed_log_messages.append(msg % args)
        logging_swap = self.swap(logging, 'error', _mock_logging_function)

        self.save_new_default_exploration('ID', 'owner_id')
        exp_snapshot_id = exp_models.ExplorationModel.get_snapshot_id('ID', 1)
        exp_snapshot = exp_models.ExplorationSnapshotMetadataModel.get_by_id(
            exp_snapshot_id)
        exp_snapshot.commit_cmds[0] = {}
        exp_snapshot.update_timestamps()
        exp_models.ExplorationSnapshotMetadataModel.put(exp_snapshot)

        with logging_swap:
            exp_services.regenerate_missing_stats_for_exploration('ID')
        self.assertEqual(
            observed_log_messages,
            [
                'Exploration(id=\'ID\') snapshots contains invalid '
                'commit_cmd: {}'
            ]
        )

    def test_handle_state_name_is_not_found_in_state_stats_mapping(
        self
    ) -> None:
        exp_id = 'ID1'
        owner_id = 'owner_id'
        self.save_new_default_exploration(exp_id, 'owner_id')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 1'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 2'
            })], 'Changed title.')
        exp_stats_list = (
            stats_services.get_multiple_exploration_stats_by_version(
                exp_id, [1, 2, 3]))
        assert exp_stats_list[0] is not None
        exp_stats_list[0].state_stats_mapping['new'] = (
            exp_stats_list[0].state_stats_mapping['Introduction'])
        del exp_stats_list[0].state_stats_mapping['Introduction']
        stats_services.save_stats_model(exp_stats_list[0])
        exp_stats_model_to_delete = (
            stats_models.ExplorationStatsModel.get_model(exp_id, 3)
        )
        assert exp_stats_model_to_delete is not None
        exp_stats_model_to_delete.delete()
        error_message = (
            r'Exploration\(id=.*, exp_version=1\) has no State\(name=.*\)')
        with self.assertRaisesRegex(Exception, error_message):
            exp_services.regenerate_missing_stats_for_exploration(exp_id)

    def test_handle_missing_exp_stats_for_reverted_exp_version(self) -> None:
        exp_id = 'ID1'
        owner_id = 'owner_id'
        self.save_new_default_exploration(exp_id, 'owner_id')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 1'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 2'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 3'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 4'
            })], 'Changed title.')
        exp_services.revert_exploration(owner_id, exp_id, 5, 4)
        exp_stats_model_to_delete = (
            stats_models.ExplorationStatsModel.get_model(exp_id, 6)
        )
        assert exp_stats_model_to_delete is not None
        exp_stats_model_to_delete.delete()

        self.assertItemsEqual(
            exp_services.regenerate_missing_stats_for_exploration('ID1'),
            (
                [
                    'ExplorationStats(exp_id=\'ID1\', exp_version=6)',
                ], [], 5, 6
            )
        )

    def test_handle_missing_state_stats_for_reverted_exp_version(self) -> None:
        exp_id = 'ID1'
        owner_id = 'owner_id'
        self.save_new_default_exploration(exp_id, 'owner_id')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 1'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 2'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 3'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 4'
            })], 'Changed title.')
        exp_services.revert_exploration(owner_id, exp_id, 5, 4)
        exp_stats = stats_services.get_exploration_stats_by_id(exp_id, 6)
        assert exp_stats is not None
        exp_stats.state_stats_mapping = {}
        stats_services.save_stats_model(exp_stats)

        self.assertItemsEqual(
            exp_services.regenerate_missing_stats_for_exploration('ID1'),
            (
                [], [
                    'StateStats(exp_id=\'ID1\', exp_version=6, '
                    'state_name=\'Introduction\')'
                ], 5, 6
            )
        )

    def test_when_few_exp_stats_models_are_missing(self) -> None:
        exp_id = 'ID1'
        owner_id = 'owner_id'
        self.save_new_default_exploration(exp_id, 'owner_id')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 1'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 2'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 3'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 4'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 5'
            })], 'Changed title.')

        exp_stats_model_for_version_2 = (
            stats_models.ExplorationStatsModel.get_model(exp_id, 2)
        )
        exp_stats_model_for_version_4 = (
            stats_models.ExplorationStatsModel.get_model(exp_id, 4)
        )
        assert exp_stats_model_for_version_2 is not None
        assert exp_stats_model_for_version_4 is not None
        exp_stats_model_for_version_2.delete()
        exp_stats_model_for_version_4.delete()

        self.assertItemsEqual(
            exp_services.regenerate_missing_stats_for_exploration('ID1'),
            (
                [
                    'ExplorationStats(exp_id=\'ID1\', exp_version=2)',
                    'ExplorationStats(exp_id=\'ID1\', exp_version=4)'
                ], [], 4, 6
            )
        )

    def test_when_v1_version_exp_stats_model_is_missing(self) -> None:
        exp_id = 'ID1'
        owner_id = 'owner_id'
        self.save_new_default_exploration(exp_id, 'owner_id')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 1'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 2'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 3'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 4'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 5'
            })], 'Changed title.')
        exp_stats_model_for_version_1 = (
            stats_models.ExplorationStatsModel.get_model(exp_id, 1)
        )
        assert exp_stats_model_for_version_1 is not None
        exp_stats_model_for_version_1.delete()

        exp_stats_model_for_version_2 = (
            stats_models.ExplorationStatsModel.get_model(exp_id, 2)
        )
        assert exp_stats_model_for_version_2 is not None
        exp_stats_model_for_version_2.delete()

        exp_stats_model_for_version_3 = (
            stats_models.ExplorationStatsModel.get_model(exp_id, 3)
        )
        assert exp_stats_model_for_version_3 is not None
        exp_stats_model_for_version_3.delete()

        self.assertItemsEqual(
            exp_services.regenerate_missing_stats_for_exploration('ID1'),
            (
                [
                    'ExplorationStats(exp_id=\'ID1\', exp_version=1)',
                    'ExplorationStats(exp_id=\'ID1\', exp_version=2)',
                    'ExplorationStats(exp_id=\'ID1\', exp_version=3)'
                ], [], 3, 6
            )
        )

    def test_generate_exp_stats_when_revert_commit_is_present(self) -> None:
        exp_id = 'ID1'
        owner_id = 'owner_id'
        self.save_new_default_exploration(exp_id, 'owner_id')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 1'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 2'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 3'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 4'
            })], 'Changed title.')
        exp_services.revert_exploration(owner_id, exp_id, 5, 3)

        exp_stats_model_for_version_1 = (
            stats_models.ExplorationStatsModel.get_model(exp_id, 1)
        )
        assert exp_stats_model_for_version_1 is not None
        exp_stats_model_for_version_1.delete()

        exp_stats_model_for_version_2 = (
            stats_models.ExplorationStatsModel.get_model(exp_id, 2)
        )
        assert exp_stats_model_for_version_2 is not None
        exp_stats_model_for_version_2.delete()

        self.assertItemsEqual(
            exp_services.regenerate_missing_stats_for_exploration('ID1'),
            (
                [
                    'ExplorationStats(exp_id=\'ID1\', exp_version=1)',
                    'ExplorationStats(exp_id=\'ID1\', exp_version=2)'
                ], [], 4, 6
            )
        )

    def test_when_all_exp_stats_models_are_missing(self) -> None:
        exp_id = 'ID1'
        owner_id = 'owner_id'
        self.save_new_default_exploration(exp_id, owner_id)
        exp_stats_model_for_version_1 = (
            stats_models.ExplorationStatsModel.get_model(exp_id, 1)
        )
        assert exp_stats_model_for_version_1 is not None
        exp_stats_model_for_version_1.delete()

        with self.assertRaisesRegex(
            Exception, 'No ExplorationStatsModels found'):
            exp_services.regenerate_missing_stats_for_exploration('ID1')

    def test_when_few_state_stats_models_are_missing(self) -> None:
        exp_id = 'ID1'
        owner_id = 'owner_id'
        self.save_new_default_exploration(exp_id, 'owner_id')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 1'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 2'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 3'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 4'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 5'
            })], 'Changed title.')
        exp_stats = stats_services.get_exploration_stats_by_id(exp_id, 2)
        assert exp_stats is not None
        exp_stats.state_stats_mapping = {}
        stats_services.save_stats_model(exp_stats)

        self.assertItemsEqual(
            exp_services.regenerate_missing_stats_for_exploration('ID1'),
            (
                [],
                [
                    'StateStats(exp_id=\'ID1\', exp_version=2, '
                    'state_name=\'Introduction\')'
                ], 6, 5
            )
        )

    def test_when_few_state_stats_models_are_missing_for_old_exps(
        self
    ) -> None:
        exp_id = 'ID1'
        owner_id = 'owner_id'
        self.save_new_valid_exploration(
            exp_id, owner_id, title='title', category='Category 1',
            end_state_name='END')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 2'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 3'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 4'
            })], 'Changed title.')
        exp_services.update_exploration(
            owner_id, exp_id, [exp_domain.ExplorationChange({
                'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
                'property_name': 'title',
                'new_value': 'New title 5'
            })], 'Changed title.')
        exp_stats = stats_services.get_exploration_stats_by_id(exp_id, 2)
        assert exp_stats is not None
        exp_stats.state_stats_mapping = {}
        stats_services.save_stats_model(exp_stats)

        self.assertItemsEqual(
            exp_services.regenerate_missing_stats_for_exploration('ID1'),
            (
                [],
                [
                    'StateStats(exp_id=\'ID1\', exp_version=2, '
                    'state_name=\'Introduction\')',
                    'StateStats(exp_id=\'ID1\', exp_version=2, '
                    'state_name=\'END\')',
                ], 8, 5
            )
        )