# Copyright 2020 Google Inc.
#
# 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.

"""Tests for the features_api module, verifying feature CRUD operations and permissions."""

import logging
from datetime import datetime
from unittest import mock

import flask
import werkzeug.exceptions  # Flask HTTP stuff.

import testing_config  # Must be imported before the module under test.
from api import features_api
from framework import rediscache
from internals import core_enums, user_models
from internals.core_models import FeatureEntry, MilestoneSet, Stage
from internals.data_types import CHANGED_FIELDS_LIST_TYPE
from internals.review_models import Activity, Gate

test_app = flask.Flask(__name__)


CHANNEL_DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S'


def _datetime_to_str(dt):
    return datetime.strftime(dt, CHANNEL_DATETIME_FORMAT)


class FeaturesAPITestDelete(testing_config.CustomTestCase):
    """Tests for deleting features via FeaturesAPI."""

    def setUp(self):
        """Set up the test."""
        logging.disable(logging.CRITICAL)
        self.feature_1 = FeatureEntry(
            name='feature one',
            summary='sum',
            category=1,
            intent_stage=core_enums.INTENT_IMPLEMENT,
            creator_email='creator@example.com',
            owner_emails=['owner@example.com'],
            editor_emails=['editor@example.com'],
        )
        self.feature_1.put()
        self.feature_id = self.feature_1.key.integer_id()

        self.request_path = '/api/v0/features/%d' % self.feature_id
        self.handler = features_api.FeaturesAPI()

        self.app_admin = user_models.AppUser(email='admin@example.com')
        self.app_admin.is_admin = True
        self.app_admin.put()

        self.random_user = user_models.AppUser(email='someuser@example.com')
        self.random_user.put()

    def tearDown(self):
        """Tear down the test."""
        logging.disable(logging.NOTSET)
        cache_key = '%s|%s' % (
            FeatureEntry.DEFAULT_CACHE_KEY,
            self.feature_1.key.integer_id(),
        )
        rediscache.delete(cache_key)

    def check_delete_is_valid(self, email):
        """Check that deleting a feature is valid."""
        testing_config.sign_in(email, 123567890)

        with test_app.test_request_context(self.request_path):
            actual_json = self.handler.do_delete(feature_id=self.feature_id)
        self.assertEqual({'message': 'Done'}, actual_json)

        revised_feature = FeatureEntry.get_by_id(self.feature_id)
        self.assertTrue(revised_feature.deleted)

        # Check that an activity log record was created
        activities = Activity.get_activities(self.feature_id)
        archive_activities = [
            a for a in activities if 'archived' in a.content.lower()
        ]  # noqa: E501
        self.assertEqual(1, len(archive_activities))
        archive_activity = archive_activities[0]
        self.assertEqual(email, archive_activity.author)
        self.assertIn('feature one', archive_activity.content)
        self.assertIn('archived', archive_activity.content)

    def test_delete__valid_admin(self):
        """Admin wants to soft-delete a feature."""
        self.check_delete_is_valid('admin@example.com')

    def test_delete__valid_owner(self):
        """Feature owner wants to soft-delete a feature."""
        self.check_delete_is_valid('owner@example.com')

    def test_delete__valid_editor(self):
        """Feature editor wants to soft-delete a feature."""
        self.check_delete_is_valid('editor@example.com')

    def test_delete__valid_creator(self):
        """Feature creator wants to soft-delete a feature."""
        self.check_delete_is_valid('creator@example.com')

    def test_delete__forbidden(self):
        """Regular user cannot soft-delete a feature."""
        testing_config.sign_in('one@example.com', 123567890)

        with test_app.test_request_context(self.request_path):
            with self.assertRaises(werkzeug.exceptions.Forbidden):
                self.handler.do_delete(feature_id=self.feature_id)

        revised_feature = FeatureEntry.get_by_id(self.feature_id)
        self.assertFalse(revised_feature.deleted)

    def test_delete__invalid(self):
        """We cannot soft-delete a feature without a feature_id."""
        testing_config.sign_in('admin@example.com', 123567890)

        with test_app.test_request_context(self.request_path):
            with self.assertRaises(werkzeug.exceptions.BadRequest):
                self.handler.do_delete()

        revised_feature = FeatureEntry.get_by_id(self.feature_id)
        self.assertFalse(revised_feature.deleted)

    def test_delete__not_found(self):
        """We cannot soft-delete a feature with the wrong feature_id."""
        testing_config.sign_in('admin@example.com', 123567890)

        with test_app.test_request_context(self.request_path):
            with self.assertRaises(werkzeug.exceptions.NotFound):
                self.handler.do_delete(feature_id=self.feature_id + 1)

        revised_feature = FeatureEntry.get_by_id(self.feature_id)
        self.assertFalse(revised_feature.deleted)

    def test_delete__clears_ai_content(self):
        """Deleting a feature should clear any AI-generated content."""
        self.feature_1.ai_test_eval_report = 'Some AI content'
        self.feature_1.put()

        self.check_delete_is_valid('admin@example.com')

        revised_feature = FeatureEntry.get_by_id(self.feature_id)
        self.assertIsNone(revised_feature.ai_test_eval_report)


class FeaturesAPITest(testing_config.CustomTestCase):
    """Tests for FeaturesAPI."""

    def setUp(self):
        """Set up the test."""
        logging.disable(logging.CRITICAL)
        self.feature_1 = FeatureEntry(
            name='feature one',
            summary='sum Z',
            feature_type=0,
            owner_emails=['feature_owner@example.com'],
            category=1,
            intent_stage=core_enums.INTENT_IMPLEMENT,
        )
        self.feature_1.put()
        self.feature_1_id = self.feature_1.key.integer_id()
        self.ship_stage_1 = Stage(
            feature_id=self.feature_1_id,
            stage_type=160,
            milestones=MilestoneSet(desktop_first=1),
        )
        self.ship_stage_1.put()
        self.ship_stage_1_id = self.ship_stage_1.key.integer_id()
        self.enterprise_stage = Stage(
            id=60, feature_id=self.feature_1_id, stage_type=1061
        )
        self.enterprise_stage.put()

        self.feature_2 = FeatureEntry(
            name='feature two',
            summary='sum K',
            feature_type=1,
            owner_emails=['other_owner@example.com'],
            category=1,
            intent_stage=core_enums.INTENT_IMPLEMENT,
        )
        self.feature_2.put()
        self.feature_2_id = self.feature_2.key.integer_id()
        self.ship_stage_2 = Stage(
            feature_id=self.feature_2_id,
            stage_type=260,
            milestones=MilestoneSet(desktop_first=2),
        )
        self.ship_stage_2.put()

        self.feature_3 = FeatureEntry(
            name='feature three',
            summary='sum A',
            feature_type=2,
            owner_emails=['other_owner@example.com'],
            category=1,
            intent_stage=core_enums.INTENT_IMPLEMENT,
            unlisted=True,
        )
        self.feature_3.put()
        self.feature_3_id = self.feature_3.key.integer_id()
        self.ship_stage_3 = Stage(
            feature_id=self.feature_3_id,
            stage_type=360,
            milestones=MilestoneSet(desktop_first=2),
        )
        self.ship_stage_3.put()

        self.feature_confidential = FeatureEntry(
            name='feature confidential',
            summary='sum A',
            feature_type=2,
            owner_emails=['confidential_owner@example.com'],
            category=1,
            editor_emails=['confidential_editor@example.com'],
            creator_email='confidential_creator@examplecom',
            intent_stage=core_enums.INTENT_IMPLEMENT,
            unlisted=True,
            confidential=True,
        )
        self.feature_confidential.put()
        self.feature_confidential_id = self.feature_3.key.integer_id()
        self.ship_stage_3 = Stage(
            feature_id=self.feature_3_id,
            stage_type=360,
            milestones=MilestoneSet(desktop_first=1),
        )
        self.ship_stage_3.put()

        self.request_path = '/api/v0/features'
        self.handler = features_api.FeaturesAPI()

        self.app_admin = user_models.AppUser(email='admin@example.com')
        self.app_admin.is_admin = True
        self.app_admin.put()

    def tearDown(self):
        """Tear down the test."""
        logging.disable(logging.NOTSET)

        rediscache.delete_keys_with_prefix('features')
        rediscache.delete_keys_with_prefix('FeatureEntries')
        rediscache.delete_keys_with_prefix('FeatureNames')

    def test_get__all_listed(self):
        """Get all features that are listed."""
        with test_app.test_request_context(self.request_path):
            actual = self.handler.do_get()

        # Comparing only the total number of features and name of the feature
        # as certain fields like `updated` cannot be compared
        self.assertEqual(2, len(actual['features']))
        self.assertEqual(2, actual['total_count'])
        self.assertEqual('feature two', actual['features'][0]['name'])
        self.assertEqual('feature one', actual['features'][1]['name'])

    def test_get__all_listed_feature_names(self):
        """Get all feature names that are listed."""
        url = self.request_path + '?name_only=true'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()

        # Comparing only the total number of features and names,
        # as it only returns feature names.
        self.assertEqual(2, len(actual['features']))
        self.assertEqual(2, actual['total_count'])
        self.assertEqual(6, len(actual['features'][0]))
        self.assertEqual('feature two', actual['features'][0]['name'])
        self.assertEqual(6, len(actual['features'][1]))
        self.assertEqual('feature one', actual['features'][1]['name'])

    def test_get__all_listed__pagination(self):
        """Get a pagination page features that are listed."""
        # User wants only 1 result, starting at index 0
        url = self.request_path + '?num=1'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(1, len(actual['features']))
        self.assertEqual(2, actual['total_count'])
        self.assertEqual('feature two', actual['features'][0]['name'])

        # User wants only 1 result, starting at index 1
        url = self.request_path + '?num=1&start=1'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(1, len(actual['features']))
        self.assertEqual(2, actual['total_count'])
        self.assertEqual('feature one', actual['features'][0]['name'])

        # User wants only 1 result, starting at index 2, but there are no more.
        url = self.request_path + '?num=1&start=2'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(0, len(actual['features']))
        self.assertEqual(2, actual['total_count'])

        # User wants only more results that we have
        url = self.request_path + '?num=999'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(2, len(actual['features']))
        self.assertEqual(2, actual['total_count'])
        self.assertEqual('feature two', actual['features'][0]['name'])
        self.assertEqual('feature one', actual['features'][1]['name'])

        # User wants only the result count, zero actual results.
        url = self.request_path + '?num=0'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(0, len(actual['features']))
        self.assertEqual(2, actual['total_count'])

        # User wants only 1 result, starting at index 0
        url = self.request_path + '?num=1&name_only=true'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(1, len(actual['features']))
        self.assertEqual(2, actual['total_count'])
        self.assertEqual('feature two', actual['features'][0]['name'])

        # User wants only 1 result, starting at index 1
        url = self.request_path + '?num=1&start=1&name_only=true'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(1, len(actual['features']))
        self.assertEqual(2, actual['total_count'])
        self.assertEqual('feature one', actual['features'][0]['name'])

        # User wants only 1 result, starting at index 2, but there are no more.
        url = self.request_path + '?num=1&start=2&name_only=true'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(0, len(actual['features']))
        self.assertEqual(2, actual['total_count'])

        # User wants only more results that we have
        url = self.request_path + '?num=999&name_only=true'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(2, len(actual['features']))
        self.assertEqual(2, actual['total_count'])
        self.assertEqual('feature two', actual['features'][0]['name'])
        self.assertEqual('feature one', actual['features'][1]['name'])

        # User wants only the result count, zero actual results.
        url = self.request_path + '?num=0&name_only=true'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(0, len(actual['features']))
        self.assertEqual(2, actual['total_count'])

    def test_get__all_listed__bad_pagination(self):
        """Reject requests that have bad pagination params."""
        # Malformed start parameter
        url = self.request_path + '?start=bad'
        with test_app.test_request_context(url):
            with self.assertRaises(werkzeug.exceptions.BadRequest):
                self.handler.do_get()

        # Malformed num parameter
        url = self.request_path + '?num=bad'
        with test_app.test_request_context(url):
            with self.assertRaises(werkzeug.exceptions.BadRequest):
                self.handler.do_get()

        # User wants a negative number of results
        url = self.request_path + '?num=-1'
        with test_app.test_request_context(url):
            with self.assertRaises(werkzeug.exceptions.BadRequest):
                self.handler.do_get()

        # User wants a negative offset
        url = self.request_path + '?start=-1'
        with test_app.test_request_context(url):
            with self.assertRaises(werkzeug.exceptions.BadRequest):
                self.handler.do_get()

    def test_get__all_unlisted_no_perms(self):
        """JSON feed does not include unlisted features for users who can't edit."""
        self.feature_1.unlisted = True
        self.feature_1.put()

        # No signed-in user
        with test_app.test_request_context(self.request_path):
            actual = self.handler.do_get()
        self.assertEqual(1, len(actual['features']))
        self.assertEqual(1, actual['total_count'])
        self.assertEqual('feature two', actual['features'][0]['name'])

        # Signed-in user with no permissions
        testing_config.sign_in('one@example.com', 123567890)
        with test_app.test_request_context(self.request_path):
            actual = self.handler.do_get()
        self.assertEqual(1, len(actual['features']))
        self.assertEqual(1, actual['total_count'])
        self.assertEqual('feature two', actual['features'][0]['name'])

    def test_get__all_unlisted_can_edit(self):
        """JSON feed includes unlisted features for users who may edit."""
        self.feature_1.unlisted = True
        self.feature_1.put()

        # Signed-in user with permissions
        testing_config.sign_in('admin@example.com', 123567890)
        with test_app.test_request_context(self.request_path):
            actual = self.handler.do_get()
        self.assertEqual(4, len(actual['features']))
        self.assertEqual(4, actual['total_count'])
        self.assertEqual('feature confidential', actual['features'][0]['name'])
        self.assertEqual('feature three', actual['features'][1]['name'])
        self.assertEqual('feature two', actual['features'][2]['name'])
        self.assertEqual('feature one', actual['features'][3]['name'])

    def test_get__user_query_no_sort__signed_out(self):
        """Get all features with a specified owner, unlisted not shown."""
        url = self.request_path + '?q=owner=other_owner@example.com'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(1, len(actual['features']))
        self.assertEqual(1, actual['total_count'])
        self.assertEqual('feature two', actual['features'][0]['name'])

    def test_get__user_query_no_sort__with_perms(self):
        """Get all features with a specified owner."""
        testing_config.sign_in('admin@example.com', 123567890)
        url = self.request_path + '?q=owner=feature_owner@example.com'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(1, len(actual['features']))
        self.assertEqual(1, actual['total_count'])
        self.assertEqual('feature one', actual['features'][0]['name'])

    def test_get__user_query_with_sort__signed_out(self):
        """Get all features, sorted by summary DESC, unlisted not shown."""
        url = self.request_path + '?sort=-summary'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(2, len(actual['features']))
        self.assertEqual(2, actual['total_count'])
        self.assertEqual('sum Z', actual['features'][0]['summary'])
        self.assertEqual('sum K', actual['features'][1]['summary'])

    def test_get__user_query_with_sort__with_perms(self):
        """Get all features, sorted by summary descending."""
        testing_config.sign_in('admin@example.com', 123567890)
        url = self.request_path + '?sort=-summary'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        self.assertEqual(4, len(actual['features']))
        self.assertEqual(4, actual['total_count'])
        self.assertEqual('sum Z', actual['features'][0]['summary'])
        self.assertEqual('sum K', actual['features'][1]['summary'])
        self.assertEqual('sum A', actual['features'][2]['summary'])

    def test_get__in_milestone_listed(self):
        """Get all features in a specific milestone that are listed."""
        # At least one feature is present in milestone
        with test_app.test_request_context(self.request_path + '?milestone=1'):
            actual = self.handler.do_get()
        self.assertEqual(6, len(actual['features_by_type']))
        self.assertEqual(1, actual['total_count'])
        self.assertEqual(
            1, len(actual['features_by_type']['Enabled by default'])
        )

        # No Feature is present in milestone
        with test_app.test_request_context(self.request_path + '?milestone=99'):
            actual = self.handler.do_get()
        self.assertEqual(6, len(actual['features_by_type']))
        self.assertEqual(0, actual['total_count'])
        self.assertEqual(
            0, len(actual['features_by_type']['Enabled by default'])
        )

    def test_get__in_milestone_unlisted_no_perms(self):
        """JSON feed does not include unlisted features for users who can't edit."""
        self.feature_1.unlisted = True
        self.feature_1.put()

        # No signed-in user
        with test_app.test_request_context(self.request_path + '?milestone=1'):
            actual = self.handler.do_get()
        self.assertEqual(6, len(actual['features_by_type']))
        self.assertEqual(0, actual['total_count'])
        self.assertEqual(
            0, len(actual['features_by_type']['Enabled by default'])
        )

        # Signed-in user with no permissions
        testing_config.sign_in('one@example.com', 123567890)
        with test_app.test_request_context(self.request_path + '?milestone=1'):
            actual = self.handler.do_get()
        self.assertEqual(6, len(actual['features_by_type']))
        self.assertEqual(0, actual['total_count'])
        self.assertEqual(
            0, len(actual['features_by_type']['Enabled by default'])
        )

    def test_get__in_milestone_unlisted_can_edit(self):
        """JSON feed includes unlisted features for users who may edit."""
        self.feature_1.unlisted = True
        self.feature_1.put()

        # Signed-in user with permissions
        testing_config.sign_in('admin@example.com', 123567890)

        # Feature is present in milestone
        with test_app.test_request_context(self.request_path + '?milestone=1'):
            actual = self.handler.do_get()
        self.assertEqual(6, len(actual['features_by_type']))
        self.assertEqual(2, actual['total_count'])
        self.assertEqual(
            2, len(actual['features_by_type']['Enabled by default'])
        )

        # Feature is not present in milestone
        with test_app.test_request_context(self.request_path + '?milestone=99'):
            actual = self.handler.do_get()
        self.assertEqual(6, len(actual['features_by_type']))
        self.assertEqual(0, actual['total_count'])
        self.assertEqual(
            0, len(actual['features_by_type']['Enabled by default'])
        )

    def test_get__in_milestone_invalid_query(self):
        """Invalid value of milestone should not be processed."""
        with test_app.test_request_context(
            self.request_path + '?milestone=chromium'
        ):
            with self.assertRaises(werkzeug.exceptions.BadRequest) as cm:
                self.handler.do_get()
            self.assertEqual(400, cm.exception.code)
            self.assertEqual(
                "Request parameter 'milestone' was not an int",
                cm.exception.description,
            )

    @mock.patch('internals.feature_helpers.get_features_in_release_notes')
    @mock.patch('internals.releasenotes_l10n_helpers.merge_translations')
    def test_get__release_notes_milestone(self, mock_merge, mock_get_features):
        """Verify do_get supports releaseNotesMilestone and lang translations."""
        mock_get_features.return_value = [{'id': 123, 'name': 'feature one'}]
        mock_merge.return_value = [
            {'id': 123, 'name': 'translated feature one'}
        ]

        # Scenario 1: Without lang parameter
        url = self.request_path + '?releaseNotesMilestone=138'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        mock_get_features.assert_called_once_with(milestone=138)
        mock_merge.assert_not_called()
        self.assertEqual('feature one', actual['features'][0]['name'])

        # Scenario 2: With valid lang parameter
        mock_get_features.reset_mock()
        mock_merge.reset_mock()
        url = self.request_path + '?releaseNotesMilestone=138&lang=fr'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        mock_get_features.assert_called_once_with(milestone=138)
        mock_merge.assert_called_once_with(
            [{'id': 123, 'name': 'feature one'}], 'fr'
        )
        self.assertEqual(
            'translated feature one', actual['features'][0]['name']
        )

        # Scenario 3: With invalid lang parameter (unsupported)
        mock_get_features.reset_mock()
        mock_merge.reset_mock()
        url = self.request_path + '?releaseNotesMilestone=138&lang=it'
        with test_app.test_request_context(url):
            actual = self.handler.do_get()
        mock_get_features.assert_called_once_with(milestone=138)
        mock_merge.assert_not_called()
        self.assertEqual('feature one', actual['features'][0]['name'])

    def test_get__specific_id__found(self):
        """JSON feed has just the feature requested."""
        request_path = self.request_path + '/' + str(self.feature_1_id)
        with test_app.test_request_context(request_path):
            actual = self.handler.do_get(feature_id=self.feature_1_id)
        self.assertEqual('feature one', actual['name'])

    def test_get__specific_id__not_found(self):
        """We give 404 if the requested feature was not found."""
        request_path = self.request_path + '/999'
        with test_app.test_request_context(request_path):
            with self.assertRaises(werkzeug.exceptions.NotFound):
                self.handler.do_get(feature_id=999)

    def test_get__specific_id__deleted(self):
        """We give 404 if the requested feature was deleted, unless can dit."""
        self.feature_1.deleted = True
        self.feature_1.put()

        testing_config.sign_out()
        request_path = self.request_path + '/' + str(self.feature_1_id)
        with test_app.test_request_context(request_path):
            with self.assertRaises(werkzeug.exceptions.NotFound):
                self.handler.do_get(feature_id=999)

        # Signed-in user with permissions
        testing_config.sign_in('admin@example.com', 123567890)
        with test_app.test_request_context(request_path):
            actual = self.handler.do_get(feature_id=self.feature_1_id)
        self.assertEqual('feature one', actual['name'])

    @mock.patch('internals.notifier.notify_releasenotes_reviewers')
    def test_maybe_reset_releasenotes_flags__reviewer(self, mock_notify):
        """When a release notes reviewer edits a feature, we do not reset."""
        self.feature_1.is_releasenotes_content_reviewed = True
        changed_fields: CHANGED_FIELDS_LIST_TYPE = []
        testing_config.sign_in('admin@example.com', 123567890)
        self.handler._maybe_reset_releasenotes_flags(
            self.feature_1, changed_fields
        )
        self.assertEqual(changed_fields, [])
        self.assertTrue(self.feature_1.is_releasenotes_content_reviewed)
        mock_notify.assert_not_called()

    @mock.patch('internals.notifier.notify_releasenotes_reviewers')
    def test_maybe_reset_releasenotes_flags__unneeded(self, mock_notify):
        """When a feature was not already reviewed, we do not reset."""
        self.feature_1.is_releasenotes_content_reviewed = False
        changed_fields: CHANGED_FIELDS_LIST_TYPE = []
        testing_config.sign_out()
        self.handler._maybe_reset_releasenotes_flags(
            self.feature_1, changed_fields
        )
        self.assertEqual(changed_fields, [])
        self.assertFalse(self.feature_1.is_releasenotes_content_reviewed)
        mock_notify.assert_not_called()

    @mock.patch('internals.notifier.notify_releasenotes_reviewers')
    def test_maybe_reset_releasenotes_flags__needed(self, mock_notify):
        """When a feature was already reviewed, we reset and notify."""
        self.feature_1.is_releasenotes_content_reviewed = True
        changed_fields: CHANGED_FIELDS_LIST_TYPE = []
        testing_config.sign_out()
        self.handler._maybe_reset_releasenotes_flags(
            self.feature_1, changed_fields
        )
        self.assertEqual(
            changed_fields, [('is_releasenotes_content_reviewed', True, False)]
        )
        self.assertFalse(self.feature_1.is_releasenotes_content_reviewed)
        mock_notify.assert_called_once()

    def test_patch__valid(self):
        """PATCH request successful with valid input from user with permissions."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        new_summary = 'a different summary'
        new_devtrial_instructions = 'https://example.com/instructions'
        doc_links = 'https://example.com/docs1\nhttps://example.com/docs2'
        valid_request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
                'summary': new_summary,  # str
                'owner_emails': 'test@example.com',  # emails
                'search_tags': 'tag1,tag2,tag3',  # split_str
                'devtrial_instructions': new_devtrial_instructions,  # link
                'doc_links': doc_links,
                'category': '1',  # int
                'privacy_review_status': '',  # empty int
                'prefixed': 'true',  # bool
            },
            'stages': [],
        }

        expected_changes = [
            ('summary', new_summary),
            ('owner_emails', ['test@example.com']),
            ('search_tags', ['tag1', 'tag2', 'tag3']),
            ('devtrial_instructions', new_devtrial_instructions),
            (
                'doc_links',
                ['https://example.com/docs1', 'https://example.com/docs2'],
            ),
            ('category', 1),
            ('privacy_review_status', None),
            ('prefixed', True),
        ]
        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_patch()
        # Success response should be returned.
        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )  # noqa: E501
        # Assert that changes were made.
        for field, expected_value in expected_changes:
            self.assertEqual(getattr(self.feature_1, field), expected_value)
        # Updater email field should be changed.
        self.assertIsNotNone(self.feature_1.updated)
        self.assertEqual(self.feature_1.updater_email, 'admin@example.com')

    def test_patch__mismatched_stage_feature(self):
        """PATCH request fails if stage ID belongs to another feature."""
        testing_config.sign_in('admin@example.com', 123567890)
        stage2 = Stage(feature_id=999, stage_type=150)
        stage2.put()
        request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
            },
            'stages': [
                {
                    'id': stage2.key.integer_id(),
                    'intent_thread_url': {
                        'form_field_name': 'shipped_milestone',
                        'value': 'bad_url',
                    },
                },
            ],
        }
        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(request_path, json=request_body):
            with self.assertRaises(werkzeug.exceptions.Forbidden):
                self.handler.do_patch()

    def test_patch__stage_changes(self):
        """Valid PATCH updates stage entities."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        new_intent_url = 'https://example.com/intent'
        valid_request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
            },
            'stages': [
                {
                    'id': self.ship_stage_1_id,
                    'intent_thread_url': {
                        'form_field_name': 'shipped_milestone',
                        'value': new_intent_url,
                    },
                },
            ],
        }
        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_patch()

        # Success response should be returned.
        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )
        # Assert that changes were made.
        self.assertEqual(self.ship_stage_1.intent_thread_url, new_intent_url)
        # Updater email field should be changed even with only stage changes.
        self.assertIsNotNone(self.feature_1.updated)
        self.assertEqual(self.feature_1.updater_email, 'admin@example.com')

    def test_patch__milestone_changes(self):
        """Valid PATCH updates milestone fields on stage entities."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        new_desktop_first = 100
        valid_request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
            },
            'stages': [
                {
                    'id': self.ship_stage_1_id,
                    'desktop_first': {
                        'form_field_name': 'desktop_first',
                        'value': new_desktop_first,
                    },
                },
                {
                    'id': self.enterprise_stage.key.integer_id(),
                    'desktop_first': {
                        'form_field_name': 'rollout_milestone',
                        'value': 105,
                    },
                },
            ],
        }
        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_patch()

        # Success response should be returned.
        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )
        # Assert that changes were made.
        self.assertIsNotNone(self.ship_stage_1.milestones)
        self.assertEqual(
            self.ship_stage_1.milestones.desktop_first, new_desktop_first
        )
        # Updater email field should be changed.
        self.assertIsNotNone(self.feature_1.updated)
        self.assertEqual(self.feature_1.updater_email, 'admin@example.com')
        # The rollout milestone should be set in the milestones field and the rollout_milestone field.  # noqa: E501
        self.assertEqual(self.enterprise_stage.rollout_milestone, 105)
        self.assertEqual(self.enterprise_stage.milestones.desktop_first, 105)

    def test_patch__milestone_changes_null(self):
        """Valid PATCH updates milestone fields when milestones object is null."""
        self.ship_stage_1.milestones = None
        self.ship_stage_1.put()

        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        new_desktop_first = 100
        valid_request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
            },
            'stages': [
                {
                    'id': self.ship_stage_1_id,
                    'desktop_first': {
                        'form_field_name': 'desktop_first',
                        'value': new_desktop_first,
                    },
                },
            ],
        }
        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_patch()

        # Success response should be returned.
        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )
        # Assert that changes were made.
        self.assertIsNotNone(self.ship_stage_1.milestones)
        self.assertEqual(
            self.ship_stage_1.milestones.desktop_first, new_desktop_first
        )
        # Updater email field should be changed even with only stage changes.
        self.assertIsNotNone(self.feature_1.updated)
        self.assertEqual(self.feature_1.updater_email, 'admin@example.com')

    def test_patch__no_permissions(self):
        """We give 403 if the user does not have feature edit access."""
        testing_config.sign_in('someuser@example.com', 123567890)
        request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
            },
            'stages': [],
        }
        request_path = f'{self.request_path}/{self.feature_1_id}'
        with test_app.test_request_context(request_path, json=request_body):
            with self.assertRaises(werkzeug.exceptions.Forbidden):
                self.handler.do_patch(feature_id=self.feature_1_id)

    def test_patch__invalid_fields(self):
        """PATCH request does not attempt to update invalid fields."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        bad_param = 'Not a real field'
        invalid_request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
                'bad_param': bad_param,
            },
            'stages': [
                {
                    'id': self.ship_stage_1_id,
                    'bad_param': {
                        'form_field_name': 'bad_param',
                        'value': bad_param,
                    },
                },
            ],
        }
        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(
            request_path, json=invalid_request_body
        ):
            self.handler.do_patch(feature_id=self.feature_1_id)
        # Updater email field should NOT be changed. No changes were made.
        self.assertIsNone(self.feature_1.updater_email)

    def test_patch__immutable_fields_ignored(self):
        """PATCH request ignores attempts to change fields in FIELDS_IMMUTABLE_BY_USER."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        # Set specific initial values for immutable fields.
        old_created = datetime(2020, 1, 1)
        old_updated = datetime(2020, 1, 1)
        old_ai_timestamp = datetime(2020, 1, 1)

        self.feature_1.created = old_created
        self.feature_1.creator_email = 'original_creator@example.com'
        self.feature_1.updated = old_updated
        self.feature_1.updater_email = 'original_updater@example.com'
        self.feature_1.outstanding_notifications = 5
        self.feature_1.deleted = False
        self.feature_1.star_count = 10
        self.feature_1.feature_type = 0
        self.feature_1.ai_test_eval_report = 'Original report'
        self.feature_1.ai_test_eval_run_status = 1
        self.feature_1.ai_test_eval_status_timestamp = old_ai_timestamp
        self.feature_1.put()

        request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
                'created': '2026-01-01T00:00:00',
                'creator_email': 'new_creator@example.com',
                'updated': '2026-01-01T00:00:00',
                'updater_email': 'new_updater@example.com',
                'outstanding_notifications': 99,
                'deleted': True,
                'star_count': 999,
                'feature_type': 3,  # Valid FEATURE_FIELD_DATA_TYPES, but immutable.
                'ai_test_eval_report': 'New report',
                'ai_test_eval_run_status': 2,
                'ai_test_eval_status_timestamp': '2026-01-01T00:00:00',
            },
            'stages': [],
        }
        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(request_path, json=request_body):
            response = self.handler.do_patch()

        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )

        # Read back the feature and verify NOTHING changed.
        revised_feature = FeatureEntry.get_by_id(self.feature_1_id)
        self.assertEqual(revised_feature.created, old_created)
        self.assertEqual(
            revised_feature.creator_email, 'original_creator@example.com'
        )
        self.assertEqual(revised_feature.updated, old_updated)
        self.assertEqual(
            revised_feature.updater_email, 'original_updater@example.com'
        )
        self.assertEqual(revised_feature.outstanding_notifications, 5)
        self.assertEqual(revised_feature.deleted, False)
        self.assertEqual(revised_feature.star_count, 10)
        self.assertEqual(revised_feature.feature_type, 0)
        self.assertEqual(revised_feature.ai_test_eval_report, 'Original report')
        self.assertEqual(revised_feature.ai_test_eval_run_status, 1)
        self.assertEqual(
            revised_feature.ai_test_eval_status_timestamp, old_ai_timestamp
        )

    def test_patch__accurate_as_of_ignored_value(self):
        """PATCH request ignores the provided value for accurate_as_of and sets it to now."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        old_accuracy_date = datetime(2020, 1, 1)
        self.feature_1.accurate_as_of = old_accuracy_date
        self.feature_1.outstanding_notifications = 5
        self.feature_1.put()

        request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
                'accurate_as_of': '2000-01-01T00:00:00',  # User tries to set an old date.
            },
            'stages': [],
        }
        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(request_path, json=request_body):
            self.handler.do_patch(feature_id=self.feature_1_id)

        revised_feature = FeatureEntry.get_by_id(self.feature_1_id)
        self.assertIsNotNone(revised_feature.accurate_as_of)
        # It should NOT be the user-provided date.
        self.assertNotEqual(
            revised_feature.accurate_as_of.strftime('%Y-%m-%dT%H:%M:%S'),
            '2000-01-01T00:00:00',
        )
        # It should be close to now (certainly greater than old_accuracy_date).
        self.assertTrue(revised_feature.accurate_as_of > old_accuracy_date)
        # outstanding_notifications should still be reset to 0.
        self.assertEqual(revised_feature.outstanding_notifications, 0)
        # And updated/updater_email SHOULD change because changes were made.
        self.assertEqual(revised_feature.updater_email, 'admin@example.com')
        self.assertTrue(revised_feature.updated > old_accuracy_date)

    def test_patch__immutable_and_valid_fields(self):
        """PATCH updates valid fields while ignoring immutable fields, setting updated/updater_email."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        # Set specific initial values for immutable fields.
        old_created = datetime(2020, 1, 1)
        old_updated = datetime(2020, 1, 1)
        old_ai_timestamp = datetime(2020, 1, 1)

        self.feature_1.created = old_created
        self.feature_1.creator_email = 'original_creator@example.com'
        self.feature_1.updated = old_updated
        self.feature_1.updater_email = 'original_updater@example.com'
        self.feature_1.outstanding_notifications = 5
        self.feature_1.deleted = False
        self.feature_1.star_count = 10
        self.feature_1.feature_type = 0
        self.feature_1.ai_test_eval_report = 'Original report'
        self.feature_1.ai_test_eval_run_status = 1
        self.feature_1.ai_test_eval_status_timestamp = old_ai_timestamp
        self.feature_1.summary = 'Old summary'
        self.feature_1.put()

        new_summary = 'New summary for valid change'
        request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
                'summary': new_summary,  # Valid!
                'created': '2026-01-01T00:00:00',  # Immutable!
                'creator_email': 'new_creator@example.com',  # Immutable!
                'updated': '2026-01-01T00:00:00',  # Immutable!
                'updater_email': 'new_updater@example.com',  # Immutable!
                'outstanding_notifications': 99,  # Immutable!
                'deleted': True,  # Immutable!
                'star_count': 999,  # Immutable!
                'feature_type': 3,  # Valid in api_specs, but Immutable on patch!
                'ai_test_eval_report': 'New report',  # Immutable!
                'ai_test_eval_run_status': 2,  # Immutable!
                'ai_test_eval_status_timestamp': '2026-01-01T00:00:00',  # Immutable!
            },
            'stages': [],
        }
        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(request_path, json=request_body):
            response = self.handler.do_patch()

        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )

        revised_feature = FeatureEntry.get_by_id(self.feature_1_id)
        # Verify valid change WAS made.
        self.assertEqual(revised_feature.summary, new_summary)
        # Verify immutable fields were IGNORED.
        self.assertEqual(revised_feature.created, old_created)
        self.assertEqual(
            revised_feature.creator_email, 'original_creator@example.com'
        )
        self.assertEqual(revised_feature.outstanding_notifications, 5)
        self.assertEqual(revised_feature.deleted, False)
        self.assertEqual(revised_feature.star_count, 10)
        self.assertEqual(revised_feature.feature_type, 0)
        self.assertEqual(revised_feature.ai_test_eval_report, 'Original report')
        self.assertEqual(revised_feature.ai_test_eval_run_status, 1)
        self.assertEqual(
            revised_feature.ai_test_eval_status_timestamp, old_ai_timestamp
        )
        # Verify updater_email and updated WERE changed to now/admin by the SERVER.
        self.assertEqual(revised_feature.updater_email, 'admin@example.com')
        self.assertTrue(revised_feature.updated > old_updated)

    def test_patch__accurate_as_of(self):
        """Updates accurate_as_of for accuracy verification request."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        # Add some outstanding notifications beforehand.
        self.feature_1.outstanding_notifications = 2
        old_accuracy_date = datetime(2020, 1, 1)
        self.feature_1.accurate_as_of = old_accuracy_date
        self.feature_1.put()

        valid_request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
                'accurate_as_of': True,
            },
            'stages': [
                {
                    'id': self.ship_stage_1_id,
                    'desktop_first': {
                        'form_field_name': 'shipped_milestone',
                        'value': 115,
                    },
                },
            ],
        }
        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            self.handler.do_patch(feature_id=self.feature_1_id)
        # Ensure that the accuracy verification was captured as an activity.
        activities: list[Activity] = Activity.query(
            Activity.feature_id == self.feature_1_id
        ).fetch()  # noqa: E501
        accuracy_activity = next(
            (
                a
                for a in activities
                if any(
                    amend.field_name == 'accurate_as_of'
                    for amend in a.amendments
                )
            ),
            None,
        )
        self.assertIsNotNone(accuracy_activity)
        # Assert that changes were made.
        self.assertIsNotNone(self.feature_1.accurate_as_of)
        self.assertTrue(self.feature_1.accurate_as_of > old_accuracy_date)
        self.assertEqual(self.feature_1.outstanding_notifications, 0)
        # Updater email field should be changed.
        self.assertIsNotNone(self.feature_1.updated)
        self.assertEqual(self.feature_1.updater_email, 'admin@example.com')

    def _setup_notification_test(self):
        """Helper to sign in admin and set outstanding notifications."""
        testing_config.sign_in('admin@example.com', 123567890)
        self.feature_1.outstanding_notifications = 5
        self.feature_1.put()

    def _run_notification_test(
        self,
        form_field_name,
        new_value,
        expected_notifications,
        milestone_key='desktop_first',
    ):
        """Helper to run a patch test and check notifications."""
        self._setup_notification_test()

        request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
            },
            'stages': [
                {
                    'id': self.ship_stage_1_id,
                    milestone_key: {
                        'form_field_name': form_field_name,
                        'value': new_value,
                    },
                },
            ],
        }
        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(request_path, json=request_body):
            response = self.handler.do_patch()

        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )
        self.assertEqual(
            self.feature_1.outstanding_notifications, expected_notifications
        )

    def test_patch__reset_notifications_on_rollout_milestone_change(self):
        """Notifications are reset when 'rollout_milestone' is changed."""
        self._run_notification_test(
            'rollout_milestone', 101, 0, milestone_key='desktop_first'
        )

    def test_patch__reset_notifications_on_shipped_milestone_change(self):
        """Notifications are reset when 'shipped_milestone' is changed."""
        self._run_notification_test(
            'shipped_milestone', 102, 0, milestone_key='desktop_first'
        )

    def test_patch__reset_notifications_on_shipped_android_milestone_change(
        self,
    ):
        """Notifications are reset when 'shipped_android_milestone' is changed."""
        self._run_notification_test(
            'shipped_android_milestone', 103, 0, milestone_key='android_first'
        )

    def test_patch__reset_notifications_on_shipped_ios_milestone_change(self):
        """Notifications are reset when 'shipped_ios_milestone' is changed."""
        self._run_notification_test(
            'shipped_ios_milestone', 104, 0, milestone_key='ios_first'
        )

    def test_patch__reset_notifications_on_shipped_webview_milestone_change(
        self,
    ):
        """Notifications are reset when 'shipped_webview_milestone' is changed."""
        self._run_notification_test(
            'shipped_webview_milestone', 105, 0, milestone_key='webview_first'
        )

    def test_patch__no_reset_notifications_on_other_stage_field_change(self):
        """Notifications are NOT reset for non-milestone stage fields."""
        self._setup_notification_test()
        new_intent_url = 'https://example.com/intent-notify-test'
        request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
            },
            'stages': [
                {
                    'id': self.ship_stage_1_id,
                    'intent_thread_url': {
                        'form_field_name': 'intent_thread_url',
                        'value': new_intent_url,
                    },
                },
            ],
        }
        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(request_path, json=request_body):
            response = self.handler.do_patch()

        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )
        updated_feature = FeatureEntry.get_by_id(self.feature_1_id)
        self.assertEqual(updated_feature.outstanding_notifications, 5)

    @mock.patch('api.channels_api.construct_specified_milestones_details')
    def test_patch__enterprise_first_notice_wrong_non_enterprise_feature(
        self, mock_call
    ):  # noqa: E501
        """PATCH request successful with no changes to first_enterprise_notification_milestone."""  # noqa: E501
        stable_date = _datetime_to_str(
            datetime.now().replace(year=datetime.now().year + 1, day=1)
        )  # noqa: E501
        mock_call.return_value = {
            100: {'version': 100, 'stable_date': stable_date}
        }  # noqa: E501

        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        valid_request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
                'first_enterprise_notification_milestone': 100,  # str
            },
            'stages': [],
        }

        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_patch()
        # Success response should be returned.
        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )  # noqa: E501
        # Assert that changes were made.
        self.assertEqual(
            getattr(self.feature_1, 'first_enterprise_notification_milestone'),
            None,
        )  # noqa: E501
        # Updater email field should be changed.
        self.assertIsNotNone(self.feature_1.updated)
        self.assertIsNone(self.feature_1.updater_email)

    @mock.patch('api.channels_api.construct_specified_milestones_details')
    def test_patch__enterprise_first_notice_enterprise_feature(self, mock_call):
        """PATCH request successful with provided first_enterprise_notification_milestone."""  # noqa: E501
        stable_date = _datetime_to_str(
            datetime.now().replace(year=datetime.now().year, day=1)
        )
        next_stable_date = _datetime_to_str(
            datetime.now().replace(year=datetime.now().year + 1, day=1)
        )
        mock_call.return_value = {
            100: {'version': 100, 'stable_date': stable_date},
            101: {'version': 101, 'stable_date': next_stable_date},
        }

        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)
        self.feature_1.feature_type = 4
        self.feature_1.put()

        valid_request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
                'first_enterprise_notification_milestone': 100,  # str
            },
            'stages': [],
        }

        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_patch()
        # Success response should be returned.
        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )  # noqa: E501
        # Assert that changes were made.
        self.assertEqual(
            getattr(self.feature_1, 'first_enterprise_notification_milestone'),
            100,
        )  # noqa: E501
        # Updater email field should be changed.
        self.assertIsNotNone(self.feature_1.updated)
        self.assertEqual(self.feature_1.updater_email, 'admin@example.com')

    @mock.patch('api.channels_api.construct_specified_milestones_details')
    def test_patch__enterprise_first_notice_newly_breaking_feature(
        self, mock_call
    ):  # noqa: E501
        """PATCH request successful with provided first_enterprise_notification_milestone."""  # noqa: E501
        stable_date = _datetime_to_str(
            datetime.now().replace(year=datetime.now().year, day=1)
        )
        next_stable_date = _datetime_to_str(
            datetime.now().replace(year=datetime.now().year + 1, day=1)
        )
        mock_call.return_value = {
            100: {'version': 100, 'stable_date': stable_date},
            101: {'version': 101, 'stable_date': next_stable_date},
        }

        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        valid_request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
                'first_enterprise_notification_milestone': 100,  # str
                'enterprise_impact': core_enums.ENTERPRISE_IMPACT_LOW,
            },
            'stages': [],
        }

        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_patch()
        # Success response should be returned.
        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )  # noqa: E501
        # Assert that changes were made.
        self.assertEqual(
            getattr(self.feature_1, 'first_enterprise_notification_milestone'),
            100,
        )  # noqa: E501
        # Updater email field should be changed.
        self.assertIsNotNone(self.feature_1.updated)
        self.assertEqual(self.feature_1.updater_email, 'admin@example.com')

    @mock.patch('api.channels_api.construct_specified_milestones_details')
    def test_patch__enterprise_first_notice_becomes_not_breaking_feature(
        self, mock_call
    ):  # noqa: E501
        """PATCH request successful with first_enterprise_notification_milestone deleted."""  # noqa: E501
        stable_date = _datetime_to_str(
            datetime.now().replace(year=datetime.now().year + 1, day=1)
        )  # noqa: E501
        mock_call.return_value = {
            100: {'version': 100, 'stable_date': stable_date}
        }  # noqa: E501

        self.feature_1.enterprise_impact = core_enums.ENTERPRISE_IMPACT_MEDIUM
        self.feature_1.first_enterprise_notification_milestone = 100
        self.feature_1.put()

        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        valid_request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
                'enterprise_impact': core_enums.ENTERPRISE_IMPACT_NONE,
            },
            'stages': [],
        }

        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_patch()
        # Success response should be returned.
        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )  # noqa: E501
        # Assert that changes were made.
        self.assertEqual(
            getattr(self.feature_1, 'first_enterprise_notification_milestone'),
            None,
        )  # noqa: E501
        # Updater email field should be changed.
        self.assertIsNotNone(self.feature_1.updated)
        self.assertEqual(self.feature_1.updater_email, 'admin@example.com')

    @mock.patch('api.channels_api.construct_specified_milestones_details')
    def test_patch__first_notice_becomes_not_breaking_feature_already_published(
        self, mock_call
    ):  # noqa: E501
        """PATCH request successful with first_enterprise_notification_milestone not deleted."""  # noqa: E501
        stable_date = _datetime_to_str(
            datetime.now().replace(year=datetime.now().year - 1, day=1)
        )  # noqa: E501
        mock_call.return_value = {
            100: {'version': 100, 'stable_date': stable_date}
        }  # noqa: E501

        self.feature_1.enterprise_impact = core_enums.ENTERPRISE_IMPACT_MEDIUM
        self.feature_1.first_enterprise_notification_milestone = 100
        self.feature_1.put()

        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        valid_request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
                'enterprise_impact': core_enums.ENTERPRISE_IMPACT_NONE,
            },
            'stages': [],
        }

        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_patch()
        # Success response should be returned.
        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )  # noqa: E501
        # Assert that changes were made.
        self.assertEqual(
            getattr(self.feature_1, 'first_enterprise_notification_milestone'),
            100,
        )  # noqa: E501
        # Updater email field should be changed.
        self.assertIsNotNone(self.feature_1.updated)
        self.assertEqual(self.feature_1.updater_email, 'admin@example.com')

    @mock.patch('api.channels_api.construct_chrome_channels_details')
    @mock.patch('api.channels_api.construct_specified_milestones_details')
    def test_patch__enterprise_first_notice_in_the_past(
        self, specified_mock, chrome_mock
    ):  # noqa: E501
        """PATCH request successful with newer default first_enterprise_notification_milestone."""  # noqa: E501
        stable_date = _datetime_to_str(
            datetime.now().replace(year=datetime.now().year - 2, day=1)
        )  # noqa: E501
        specified_mock.return_value = {
            100: {'version': 100, 'stable_date': stable_date}
        }  # noqa: E501
        chrome_mock.return_value = {'beta': {'version': 420}}

        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)
        self.feature_1.feature_type = 4
        self.feature_1.put()

        valid_request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
                'first_enterprise_notification_milestone': 100,  # str
            },
            'stages': [],
        }

        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_patch()
        # Success response should be returned.
        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )  # noqa: E501
        # Assert that changes were made.
        self.assertEqual(
            getattr(self.feature_1, 'first_enterprise_notification_milestone'),
            420,
        )  # noqa: E501
        # Updater email field should be changed.
        self.assertIsNotNone(self.feature_1.updated)
        self.assertEqual(self.feature_1.updater_email, 'admin@example.com')

    @mock.patch('api.channels_api.construct_specified_milestones_details')
    def test_patch__enterprise_first_notice_already_published(self, mock_call):
        """PATCH request successful with no changes to first_enterprise_notification_milestone."""  # noqa: E501
        now = datetime.now()
        mock_call.return_value = {
            100: {
                'version': 100,
                'stable_date': _datetime_to_str(
                    now.replace(year=now.year - 1, day=1)
                ),
            },
            101: {
                'version': 101,
                'stable_date': _datetime_to_str(
                    now.replace(year=now.year + 1, day=1)
                ),
            },
        }

        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)
        self.feature_1.feature_type = 4
        self.feature_1.first_enterprise_notification_milestone = 100
        self.feature_1.put()
        valid_request_body = {
            'feature_changes': {
                'id': self.feature_1_id,
                'first_enterprise_notification_milestone': 101,  # str
            },
            'stages': [],
        }

        request_path = f'{self.request_path}/update'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_patch()
        # Success response should be returned.
        self.assertEqual(
            {'message': f'Feature {self.feature_1_id} updated.'}, response
        )  # noqa: E501
        # Assert that changes were made.
        self.assertEqual(
            getattr(self.feature_1, 'first_enterprise_notification_milestone'),
            100,
        )  # noqa: E501
        # Updater email field should be changed.
        self.assertIsNotNone(self.feature_1.updated)
        self.assertIsNone(self.feature_1.updater_email)

    def test_shared_update_special_fields__no_markdown_changes(self):
        """Test updating special fields without markdown changes."""
        feature_changes = {'some_other_field': 'some value'}
        self.handler._shared_update_special_fields(
            self.feature_1, feature_changes
        )
        self.assertEqual([], self.feature_1.markdown_fields)

        self.feature_1.markdown_fields = ['summary']
        self.handler._shared_update_special_fields(
            self.feature_1, feature_changes
        )
        self.assertEqual(['summary'], self.feature_1.markdown_fields)

    def test_shared_update_special_fields__add_markdown(self):
        """Test updating special fields with added markdown."""
        feature_changes = {'summary_is_markdown': True}
        self.handler._shared_update_special_fields(
            self.feature_1, feature_changes
        )
        self.assertEqual(['summary'], self.feature_1.markdown_fields)

        feature_changes = {'motivation_is_markdown': True}
        self.handler._shared_update_special_fields(
            self.feature_1, feature_changes
        )
        self.assertEqual(
            ['summary', 'motivation'], self.feature_1.markdown_fields
        )

    def test_shared_update_special_fields__remove_markdown(self):
        """Test updating special fields with removed markdown."""
        self.feature_1.markdown_fields = ['summary', 'motivation']
        feature_changes = {'summary_is_markdown': False}
        self.handler._shared_update_special_fields(
            self.feature_1, feature_changes
        )
        self.assertEqual(['motivation'], self.feature_1.markdown_fields)

        feature_changes = {'motivation_is_markdown': False}
        self.handler._shared_update_special_fields(
            self.feature_1, feature_changes
        )
        self.assertEqual([], self.feature_1.markdown_fields)

    def test_post__valid(self):
        """POST request successful with valid input from user with permissions."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        valid_request_body = {
            'name': 'A name',
            'summary': 'A summary',
            'owner_emails': 'user@example.com,user2@example.com',
            'category': 2,
            'feature_type': 1,
            'impl_status_chrome': 3,
            'standard_maturity': 2,
            'ff_views': 1,
            'safari_views': 1,
            'web_dev_views': 1,
            'wpt': True,
        }

        request_path = f'{self.request_path}/create'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_post()
        # A new feature ID should be returned.
        self.assertIsNotNone(response['feature_id'])
        self.assertTrue(type(response['feature_id']) == int)  # noqa: E721
        # New feature should exist.
        new_feature: FeatureEntry | None = FeatureEntry.get_by_id(
            response['feature_id']
        )
        self.assertIsNotNone(new_feature)

        # New feature's values should match fields in JSON body.
        for field, value in valid_request_body.items():
            if field == 'owner_emails':
                # list field types should convert the string into a list.
                self.assertEqual(
                    new_feature.owner_emails,
                    ['user@example.com', 'user2@example.com'],
                )  # noqa: E501
            else:
                self.assertEqual(getattr(new_feature, field), value)
        # User's email should match creator_email field.
        self.assertEqual(new_feature.creator_email, 'admin@example.com')

        # Check that an activity log record was created
        activities = Activity.get_activities(response['feature_id'])
        self.assertEqual(1, len(activities))
        activity = activities[0]
        self.assertEqual('admin@example.com', activity.author)
        self.assertEqual(Activity.USER_CHANGE, activity.log_type)
        self.assertEqual('Feature entry created', activity.content)

    def test_post__valid_stage_and_gate_creation(self):
        """POST request successful with valid input from user with permissions."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        valid_request_body = {
            'name': 'A name',
            'summary': 'A summary',
            'owner_emails': 'user@example.com,user2@example.com',
            'category': 2,
            'feature_type': 0,
            'impl_status_chrome': 3,
            'standard_maturity': 2,
            'ff_views': 1,
            'safari_views': 1,
            'web_dev_views': 1,
        }

        request_path = f'{self.request_path}/create'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_post()
        # A new feature ID should be returned.
        self.assertIsNotNone(response['feature_id'])
        self.assertTrue(type(response['feature_id']) == int)  # noqa: E721
        # New feature should exist.
        new_feature: FeatureEntry | None = FeatureEntry.get_by_id(
            response['feature_id']
        )
        self.assertIsNotNone(new_feature)

        # Ensure Stage and Gate entities were created.
        stages = Stage.query(
            Stage.feature_id == new_feature.key.integer_id()
        ).fetch()
        gates = Gate.query(
            Gate.feature_id == new_feature.key.integer_id()
        ).fetch()
        self.assertEqual(len(stages), 6)
        self.assertEqual(len(gates), 12)

    def test_post__no_permissions(self):
        """403 Forbidden if the user does not have feature create access."""
        testing_config.sign_in('someuser@example.com', 123567890)

        request_path = f'{self.request_path}/create'
        with test_app.test_request_context(request_path, json={}):
            with self.assertRaises(werkzeug.exceptions.Forbidden):
                self.handler.do_post()

    def test_post__invalid_fields(self):
        """POST request fails with 400 when supplying invalid fields."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        request_body = {
            'bad_param': 'Not a real field',  # Bad field.
            'name': 'A name',
            'summary': 'A summary',
            'owner_emails': 'user@example.com,user2@example.com',
            'category': 1,
            'feature_type': 1,
            'impl_status_chrome': 1,
            'standard_maturity': 1,
            'ff_views': 1,
            'safari_views': 1,
            'web_dev_views': 1,
        }
        request_path = f'{self.request_path}/create'
        with test_app.test_request_context(request_path, json=request_body):
            response = self.handler.do_post()
        # A new feature ID should be returned.
        self.assertIsNotNone(response['feature_id'])
        self.assertTrue(type(response['feature_id']) == int)  # noqa: E721
        # New feature should exist.
        new_feature: FeatureEntry | None = FeatureEntry.get_by_id(
            response['feature_id']
        )
        self.assertIsNotNone(new_feature)

        # New feature's values should match fields in JSON body (except bad param).
        for field, value in request_body.items():
            # Invalid fields are ignored and not updated.
            if field == 'bad_param':
                continue
            if field == 'owner_emails':
                # list field types should convert the string into a list.
                self.assertEqual(
                    new_feature.owner_emails,
                    ['user@example.com', 'user2@example.com'],
                )  # noqa: E501
            else:
                self.assertEqual(getattr(new_feature, field), value)

    def test_post__immutable_fields(self):
        """POST request fails with 400 when immutable field is provided."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        request_body = {
            'creator_email': 'differentuser@example.com',  # Immutable.
            'name': 'A name',
            'summary': 'A summary',
            'owner_emails': 'user@example.com,user2@example.com',
            'category': 1,
            'feature_type': 1,
            'impl_status_chrome': 1,
            'standard_maturity': 1,
            'ff_views': 1,
            'safari_views': 1,
            'web_dev_views': 1,
        }
        request_path = f'{self.request_path}/create'
        with test_app.test_request_context(request_path, json=request_body):
            response = self.handler.do_post()
        # A new feature ID should be returned.
        self.assertIsNotNone(response['feature_id'])
        self.assertTrue(type(response['feature_id']) == int)  # noqa: E721
        # New feature should exist.
        new_feature: FeatureEntry | None = FeatureEntry.get_by_id(
            response['feature_id']
        )
        self.assertIsNotNone(new_feature)

        # New feature's values should match fields in JSON body
        # (except immutable field).
        for field, value in request_body.items():
            if field == 'creator_email':
                # User's email should match creator_email field.
                # The given creator_email should be ignored.
                self.assertEqual(new_feature.creator_email, 'admin@example.com')
            elif field == 'owner_emails':
                # list field types should convert the string into a list.
                self.assertEqual(
                    new_feature.owner_emails,
                    ['user@example.com', 'user2@example.com'],
                )
            else:
                self.assertEqual(getattr(new_feature, field), value)

    def test_post__all_immutable_fields_ignored(self):
        """POST request succeeds while ignoring all immutable fields provided in the body."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        request_body = {
            'name': 'A name for create',
            'summary': 'A summary for create',
            'category': 1,
            'feature_type': 1,  # Mutable on create!
            'impl_status_chrome': 1,
            'standard_maturity': 1,
            'ff_views': 1,
            'safari_views': 1,
            'web_dev_views': 1,
            # Immutable fields that should be IGNORED:
            'creator_email': 'evil_creator@example.com',
            'updater_email': 'evil_updater@example.com',
            'created': '2000-01-01T00:00:00',
            'updated': '2000-01-01T00:00:00',
            'accurate_as_of': '2000-01-01T00:00:00',
            'outstanding_notifications': 99,
            'deleted': True,
            'star_count': 999,
            'ai_test_eval_report': 'Evil report',
            'ai_test_eval_run_status': 2,
            'ai_test_eval_status_timestamp': '2000-01-01T00:00:00',
        }
        request_path = f'{self.request_path}/create'
        with test_app.test_request_context(request_path, json=request_body):
            response = self.handler.do_post()

        self.assertIsNotNone(response['feature_id'])
        self.assertIsInstance(response['feature_id'], int)

        new_feature: FeatureEntry | None = FeatureEntry.get_by_id(
            response['feature_id']
        )
        self.assertIsNotNone(new_feature)

        # Verify valid and required fields were set.
        self.assertEqual(new_feature.name, 'A name for create')
        self.assertEqual(new_feature.summary, 'A summary for create')
        self.assertEqual(new_feature.feature_type, 1)

        # Verify immutable fields were IGNORED or set by server.
        self.assertEqual(new_feature.creator_email, 'admin@example.com')
        self.assertEqual(new_feature.updater_email, 'admin@example.com')
        self.assertIsNotNone(new_feature.created)
        self.assertNotEqual(
            new_feature.created.strftime('%Y-%m-%dT%H:%M:%S'),
            '2000-01-01T00:00:00',
        )
        self.assertIsNotNone(new_feature.updated)
        self.assertNotEqual(
            new_feature.updated.strftime('%Y-%m-%dT%H:%M:%S'),
            '2000-01-01T00:00:00',
        )
        self.assertIsNotNone(new_feature.accurate_as_of)
        self.assertNotEqual(
            new_feature.accurate_as_of.strftime('%Y-%m-%dT%H:%M:%S'),
            '2000-01-01T00:00:00',
        )
        self.assertEqual(new_feature.outstanding_notifications, 0)
        self.assertEqual(new_feature.deleted, False)
        self.assertEqual(new_feature.star_count, 0)
        self.assertIsNone(new_feature.ai_test_eval_report)
        self.assertIsNone(new_feature.ai_test_eval_run_status)
        self.assertIsNone(new_feature.ai_test_eval_status_timestamp)

    def test_post__bad_data_type_int(self):
        """POST request fails with 400 when a bad int data type is provided."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        invalid_request_body = {
            'name': 'A name',
            'summary': 'A summary',
            'owner_emails': 'user@example.com,user2@example.com',
            'category': 'THIS SHOULD BE AN INTEGER',  # Bad data type.
            'feature_type': 1,
            'impl_status_chrome': 1,
            'standard_maturity': 1,
            'ff_views': 1,
            'safari_views': 1,
            'web_dev_views': 1,
        }
        request_path = f'{self.request_path}/create'
        with test_app.test_request_context(
            request_path, json=invalid_request_body
        ):
            with self.assertRaises(werkzeug.exceptions.BadRequest):
                self.handler.do_post()

    def test_post__bad_data_type_list(self):
        """POST request fails with 400 when a bad list data type is provided."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        invalid_request_body = {
            'name': 'A name',
            'summary': 'A summary',
            'owner_emails': 12345,  # Bad data type.
            'category': 1,
            'feature_type': 1,
            'impl_status_chrome': 1,
            'standard_maturity': 1,
            'ff_views': 1,
            'safari_views': 1,
            'web_dev_views': 1,
        }
        request_path = f'{self.request_path}/create'
        with test_app.test_request_context(
            request_path, json=invalid_request_body
        ):
            with self.assertRaises(werkzeug.exceptions.BadRequest):
                self.handler.do_post()

    def test_post__missing_required_field(self):
        """POST request fails with 400 when missing required fields."""
        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        invalid_request_body = {
            # No 'name' field.
            'summary': 'A summary',
            'owner_emails': 'owner1@example.com,owner2@example.com',
            'category': 1,
            'feature_type': 1,
            'impl_status_chrome': 1,
            'standard_maturity': 1,
            'ff_views': 1,
            'safari_views': 1,
            'web_dev_views': 1,
        }
        request_path = f'{self.request_path}/create'
        with test_app.test_request_context(
            request_path, json=invalid_request_body
        ):
            with self.assertRaises(werkzeug.exceptions.BadRequest):
                self.handler.do_post()

    @mock.patch('api.channels_api.construct_chrome_channels_details')
    def test_post__first_enterprise_notification_milestone_missing_enterprise(
        self, mock_call
    ):  # noqa: E501
        """POST request successful with default first_enterprise_notification_milestone."""  # noqa: E501
        expected = {'beta': {'version': 420}}
        mock_call.return_value = expected

        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        valid_request_body = {
            'name': 'A name',
            'summary': 'A summary',
            'owner_emails': 'user@example.com,user2@example.com',
            'category': 2,
            'feature_type': 4,
            'impl_status_chrome': 3,
            'standard_maturity': 2,
            'ff_views': 1,
            'safari_views': 1,
            'web_dev_views': 1,
            'wpt': True,
        }

        request_path = f'{self.request_path}/create'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_post()

        # New feature should exist.
        new_feature: FeatureEntry | None = FeatureEntry.get_by_id(
            response['feature_id']
        )
        self.assertIsNotNone(new_feature)

        # New feature's values should match fields in JSON body.
        for field, value in valid_request_body.items():
            if field == 'owner_emails':
                # list field types should convert the string into a list.
                self.assertEqual(
                    new_feature.owner_emails,
                    ['user@example.com', 'user2@example.com'],
                )  # noqa: E501
            else:
                self.assertEqual(getattr(new_feature, field), value)

        # Enterprise first notice should be created.
        self.assertEqual(
            new_feature.first_enterprise_notification_milestone, 420
        )

    @mock.patch('api.channels_api.construct_chrome_channels_details')
    def test_post__first_enterprise_notification_milestone_missing_impact_enterprise(
        self, mock_call
    ):  # noqa: E501
        """POST request successful with default first_enterprise_notification_milestone."""  # noqa: E501
        expected = {'beta': {'version': 420}}
        mock_call.return_value = expected

        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        valid_request_body = {
            'name': 'A name',
            'summary': 'A summary',
            'owner_emails': 'user@example.com,user2@example.com',
            'category': 2,
            'feature_type': 1,
            'impl_status_chrome': 3,
            'standard_maturity': 2,
            'ff_views': 1,
            'safari_views': 1,
            'web_dev_views': 1,
            'enterprise_impact': core_enums.ENTERPRISE_IMPACT_LOW,
            'wpt': True,
        }

        request_path = f'{self.request_path}/create'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_post()

        # New feature should exist.
        new_feature: FeatureEntry | None = FeatureEntry.get_by_id(
            response['feature_id']
        )
        self.assertIsNotNone(new_feature)

        # New feature's values should match fields in JSON body.
        for field, value in valid_request_body.items():
            if field == 'owner_emails':
                # list field types should convert the string into a list.
                self.assertEqual(
                    new_feature.owner_emails,
                    ['user@example.com', 'user2@example.com'],
                )  # noqa: E501
            else:
                self.assertEqual(getattr(new_feature, field), value)

        # Enterprise first notice should be created.
        self.assertEqual(
            new_feature.first_enterprise_notification_milestone, 420
        )

    @mock.patch('api.channels_api.construct_chrome_channels_details')
    def test_post__first_enterprise_notification_milestone_set(self, mock_call):
        """POST request successful with provided first_enterprise_notification_milestone."""  # noqa: E501
        expected = {'beta': {'version': 420}}
        mock_call.return_value = expected

        # Signed-in user with permissions.
        testing_config.sign_in('admin@example.com', 123567890)

        valid_request_body = {
            'name': 'A name',
            'summary': 'A summary',
            'owner_emails': 'user@example.com,user2@example.com',
            'category': 2,
            'feature_type': 4,
            'enterprise_impact': core_enums.ENTERPRISE_IMPACT_HIGH,
            'impl_status_chrome': 3,
            'standard_maturity': 2,
            'ff_views': 1,
            'safari_views': 1,
            'web_dev_views': 1,
            'first_enterprise_notification_milestone': 123,
            'wpt': True,
        }

        request_path = f'{self.request_path}/create'
        with test_app.test_request_context(
            request_path, json=valid_request_body
        ):
            response = self.handler.do_post()

        # New feature should exist.
        new_feature: FeatureEntry | None = FeatureEntry.get_by_id(
            response['feature_id']
        )
        self.assertIsNotNone(new_feature)

        # New feature's values should match fields in JSON body.
        for field, value in valid_request_body.items():
            if field == 'owner_emails':
                # list field types should convert the string into a list.
                self.assertEqual(
                    new_feature.owner_emails,
                    ['user@example.com', 'user2@example.com'],
                )  # noqa: E501
            else:
                self.assertEqual(getattr(new_feature, field), value)

        # Enterprise first notice should set as specified.
        self.assertEqual(
            new_feature.first_enterprise_notification_milestone, 123
        )
