# Copyright 2023 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 reviews_api module, verifying vote recording, gate status updates, and notifications."""

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 reviews_api
from internals import approval_defs, core_enums, core_models
from internals.review_models import Gate, SurveyAnswers, Vote

test_app = flask.Flask(__name__)

NOW = datetime.datetime.now()

ALL_SHIPPING_GATE_TYPES = [
    core_enums.GATE_PRIVACY_SHIP,
    core_enums.GATE_SECURITY_SHIP,
    core_enums.GATE_ENTERPRISE_SHIP,
    core_enums.GATE_DEBUGGABILITY_SHIP,
    core_enums.GATE_TESTING_SHIP,
    core_enums.GATE_ADOPTION_SHIP,
    core_enums.GATE_API_SHIP,
]


class VotesAPITest(testing_config.CustomTestCase):
    """Tests for VotesAPI."""

    def setUp(self):
        """Set up the test environment."""
        self.feature_1 = core_models.FeatureEntry(
            name='feature one',
            summary='sum',
            category=1,
            owner_emails=['owner1@example.com'],
        )
        self.feature_1.put()
        self.feature_id = self.feature_1.key.integer_id()

        self.gate_1 = Gate(
            id=1,
            feature_id=self.feature_id,
            stage_id=1,
            gate_type=1,
            state=Vote.NA,
        )
        self.gate_1.put()
        self.gate_1_id = self.gate_1.key.integer_id()

        self.gate_2 = Gate(
            id=2,
            feature_id=self.feature_id,
            stage_id=1,
            gate_type=2,
            state=Vote.NA,
        )
        self.gate_2.put()
        self.gate_2_id = self.gate_2.key.integer_id()

        self.handler = reviews_api.VotesAPI()
        self.request_path = '/api/v0/features/%d/votes' % self.feature_id

        # These are not in the datastore unless a specific test calls put().
        self.vote_1_1 = Vote(
            feature_id=self.feature_id,
            gate_id=self.gate_1_id,
            gate_type=1,
            set_by='reviewer1@example.com',
            set_on=NOW,
            state=Vote.APPROVED,
        )
        self.vote_2_1 = Vote(
            feature_id=self.feature_id,
            gate_id=self.gate_2_id,
            gate_type=1,
            set_by='reviewer2@example.com',
            set_on=NOW,
            state=Vote.NEEDS_WORK,
        )

        self.vote_expected1 = {
            'feature_id': self.feature_id,
            'gate_id': self.gate_1_id,
            'gate_type': 1,
            'set_by': 'reviewer1@example.com',
            'set_on': str(NOW),
            'state': Vote.APPROVED,
        }
        self.vote_expected2 = {
            'feature_id': self.feature_id,
            'gate_id': self.gate_2_id,
            'gate_type': 1,
            'set_by': 'reviewer2@example.com',
            'set_on': str(NOW),
            'state': Vote.NEEDS_WORK,
        }

    def test_get__feature_empty(self):
        """We can get all votes for a given feature, even if there none."""
        testing_config.sign_out()
        with test_app.test_request_context(self.request_path):
            actual_response = self.handler.do_get(feature_id=self.feature_id)
        self.assertEqual({'votes': []}, actual_response)

    def test_get__feature_some(self):
        """We can get all votes for a given feature."""
        testing_config.sign_out()
        self.vote_1_1.put()
        self.vote_2_1.put()

        with test_app.test_request_context(self.request_path):
            actual_response = self.handler.do_get(feature_id=self.feature_id)

        self.assertEqual(
            {'votes': [self.vote_expected1, self.vote_expected2]},
            actual_response,
        )

    def test_get__gate_empty(self):
        """We can get votes for given feature and field, even if there none."""
        testing_config.sign_out()
        with test_app.test_request_context(self.request_path + '/1'):
            actual_response = self.handler.do_get(
                feature_id=self.feature_id, gate_id=self.gate_1_id
            )
        self.assertEqual({'votes': []}, actual_response)

    def test_get__gate_some(self):
        """We can get votes for a given feature and gate_id."""
        testing_config.sign_out()
        self.vote_1_1.put()  # Found.
        self.vote_2_1.put()  # On a different gate.

        with test_app.test_request_context(self.request_path + '/1'):
            actual_response = self.handler.do_get(
                feature_id=self.feature_id, gate_id=self.gate_1_id
            )

        self.assertEqual({'votes': [self.vote_expected1]}, actual_response)

    def test_post__bad_feature_id(self):
        """Handler rejects requests that don't specify an existing feature."""
        testing_config.sign_in('admin@example.com', 1234567890)
        params = {}
        with test_app.test_request_context(self.request_path, json=params):
            with self.assertRaises(werkzeug.exceptions.NotFound):
                self.handler.do_post(feature_id=999, gate_id=self.gate_1_id)

    def test_post__bad_gate_id(self):
        """Handler rejects requests that don't specify an existing gate."""
        testing_config.sign_in('admin@example.com', 1234567890)
        params = {'state': Vote.APPROVED}
        with test_app.test_request_context(self.request_path, json=params):
            with self.assertRaises(werkzeug.exceptions.NotFound):
                self.handler.do_post(feature_id=self.feature_id, gate_id=999)

    def test_post__bad_state(self):
        """Handler rejects requests that don't specify a state correctly."""
        testing_config.sign_in('user@example.com', 12345)
        params = {'state': 'not an int'}
        with test_app.test_request_context(self.request_path, json=params):
            with self.assertRaises(werkzeug.exceptions.BadRequest):
                self.handler.do_post(
                    feature_id=self.feature_id, gate_id=self.gate_1_id
                )

        params = {'state': 999}
        with test_app.test_request_context(self.request_path, json=params):
            with self.assertRaises(werkzeug.exceptions.BadRequest):
                self.handler.do_post(
                    feature_id=self.feature_id, gate_id=self.gate_1_id
                )

    @mock.patch('internals.approval_defs.get_approvers')
    def test_post__forbidden(self, mock_get_approvers):
        """Handler rejects requests from anon users and non-approvers."""
        original_updated = self.feature_1.updated
        mock_get_approvers.return_value = ['reviewer1@example.com']
        params = {'state': Vote.NEEDS_WORK}

        testing_config.sign_out()
        with test_app.test_request_context(self.request_path, json=params):
            with self.assertRaises(werkzeug.exceptions.Forbidden):
                self.handler.do_post(
                    feature_id=self.feature_id, gate_id=self.gate_1_id
                )

        testing_config.sign_in('user7@example.com', 123567890)
        with test_app.test_request_context(self.request_path, json=params):
            with self.assertRaises(werkzeug.exceptions.Forbidden):
                self.handler.do_post(
                    feature_id=self.feature_id, gate_id=self.gate_1_id
                )

        testing_config.sign_in('user@google.com', 123567890)
        with test_app.test_request_context(self.request_path, json=params):
            with self.assertRaises(werkzeug.exceptions.Forbidden):
                self.handler.do_post(
                    feature_id=self.feature_id, gate_id=self.gate_1_id
                )

        testing_config.sign_in('owner1@example.com', 123567890)
        with test_app.test_request_context(self.request_path, json=params):
            with self.assertRaises(werkzeug.exceptions.Forbidden):
                self.handler.do_post(
                    feature_id=self.feature_id, gate_id=self.gate_1_id
                )

        params = {'state': Vote.REVIEW_REQUESTED}
        testing_config.sign_in('user7@example.com', 123567890)
        with test_app.test_request_context(self.request_path, json=params):
            with self.assertRaises(werkzeug.exceptions.Forbidden):
                self.handler.do_post(
                    feature_id=self.feature_id, gate_id=self.gate_1_id
                )

        # None of these rejections changed the feature's updated time.
        self.assertEqual(original_updated, self.feature_1.updated)

    @mock.patch('internals.approval_defs.get_approvers')
    def test_post__mismatched(self, mock_get_approvers):
        """Handler rejects requests with gate of a different feature."""
        mock_get_approvers.return_value = ['reviewer1@example.com']
        params = {'state': Vote.NEEDS_WORK}

        self.gate_1.feature_id = 999
        self.gate_1.put()  # This gate belongs to some other feature.

        testing_config.sign_in('reviewer1@example.com', 123567890)
        with test_app.test_request_context(self.request_path, json=params):
            with self.assertRaises(werkzeug.exceptions.BadRequest):
                self.handler.do_post(
                    feature_id=self.feature_id, gate_id=self.gate_1_id
                )

    @mock.patch('internals.notifier_helpers.notify_subscribers_of_vote_changes')
    @mock.patch('internals.approval_defs.get_approvers')
    def test_post__add_new_vote(self, mock_get_approvers, mock_notifier):
        """Handler adds a vote when one did not exist before."""
        original_updated = self.feature_1.updated
        mock_get_approvers.return_value = ['reviewer1@example.com']
        testing_config.sign_in('reviewer1@example.com', 123567890)
        params = {'state': Vote.NEEDS_WORK}
        with test_app.test_request_context(self.request_path, json=params):
            actual = self.handler.do_post(
                feature_id=self.feature_id, gate_id=self.gate_1_id
            )

        self.assertEqual(actual, {'message': 'Done'})
        updated_votes = Vote.get_votes(feature_id=self.feature_id)
        self.assertEqual(1, len(updated_votes))
        vote = updated_votes[0]
        self.assertEqual(vote.feature_id, self.feature_id)
        self.assertEqual(vote.gate_id, 1)
        self.assertEqual(vote.set_by, 'reviewer1@example.com')
        self.assertEqual(vote.state, Vote.NEEDS_WORK)

        mock_notifier.assert_called_once_with(
            self.feature_1,
            self.gate_1,
            'reviewer1@example.com',
            Vote.NEEDS_WORK,
            Vote.NO_RESPONSE,
        )
        self.assertTrue(original_updated < self.feature_1.updated)

    @mock.patch('internals.notifier_helpers.notify_subscribers_of_vote_changes')
    @mock.patch('internals.approval_defs.get_approvers')
    def test_post__update_vote(self, mock_get_approvers, mock_notifier):
        """Handler updates a vote when one already exists for that reviwer."""
        original_updated = self.feature_1.updated
        mock_get_approvers.return_value = ['reviewer1@example.com']
        testing_config.sign_in('reviewer1@example.com', 123567890)
        self.vote_1_1.put()  # Existing vote from reviewer1@.

        params = {'state': Vote.DENIED}
        with test_app.test_request_context(self.request_path, json=params):
            actual = self.handler.do_post(
                feature_id=self.feature_id, gate_id=self.gate_1_id
            )

        self.assertEqual(actual, {'message': 'Done'})
        updated_votes = Vote.get_votes(feature_id=self.feature_id)
        self.assertEqual(1, len(updated_votes))
        vote = updated_votes[0]
        self.assertEqual(vote.feature_id, self.feature_id)
        self.assertEqual(vote.gate_id, 1)
        self.assertEqual(vote.set_by, 'reviewer1@example.com')
        self.assertEqual(vote.state, Vote.DENIED)

        mock_notifier.assert_called_once_with(
            self.feature_1,
            self.gate_1,
            'reviewer1@example.com',
            Vote.DENIED,
            Vote.APPROVED,
        )
        self.assertTrue(original_updated < self.feature_1.updated)

    @mock.patch('internals.notifier_helpers.notify_approvers_of_reviews')
    @mock.patch('internals.approval_defs.get_approvers')
    def test_post__request_review(self, mock_get_approvers, mock_notifier):
        """Handler allows a feature owner to request a review."""
        mock_get_approvers.return_value = ['reviewer1@example.com']
        testing_config.sign_in('owner1@example.com', 123567890)

        params = {'state': Vote.REVIEW_REQUESTED}
        with test_app.test_request_context(self.request_path, json=params):
            actual = self.handler.do_post(
                feature_id=self.feature_id, gate_id=self.gate_1_id
            )

        self.assertEqual(actual, {'message': 'Done'})
        updated_votes = Vote.get_votes(feature_id=self.feature_id)
        self.assertEqual(1, len(updated_votes))
        vote = updated_votes[0]
        self.assertEqual(vote.feature_id, self.feature_id)
        self.assertEqual(vote.gate_id, 1)
        self.assertEqual(vote.set_by, 'owner1@example.com')
        self.assertEqual(vote.state, Vote.REVIEW_REQUESTED)

        mock_notifier.assert_called_once_with(
            self.feature_1,
            self.gate_1,
            Vote.REVIEW_REQUESTED,
            'owner1@example.com',
        )

    @mock.patch('internals.notifier_helpers.notify_subscribers_of_vote_changes')
    @mock.patch('internals.approval_defs.get_approvers')
    def test_post__self_cert__ineligible(
        self, mock_get_approvers, mock_notifier
    ):
        """Handler allows a feature owner to self-approve if eligible."""
        mock_get_approvers.return_value = ['reviewer1@example.com']
        testing_config.sign_in('owner1@example.com', 123567890)
        self.gate_1.gate_type = core_enums.GATE_PRIVACY_SHIP
        # No survey answers filled in.
        self.gate_1.put()

        params = {'state': Vote.APPROVED}
        with test_app.test_request_context(self.request_path, json=params):
            with self.assertRaises(werkzeug.exceptions.Forbidden):
                self.handler.do_post(
                    feature_id=self.feature_id, gate_id=self.gate_1_id
                )

    @mock.patch('internals.notifier_helpers.notify_subscribers_of_vote_changes')
    @mock.patch('internals.approval_defs.get_approvers')
    def test_post__self_cert__eligible(self, mock_get_approvers, mock_notifier):
        """Handler allows a feature owner to self-approve if eligible."""
        mock_get_approvers.return_value = ['reviewer1@example.com']
        testing_config.sign_in('owner1@example.com', 123567890)
        self.gate_1.gate_type = core_enums.GATE_PRIVACY_SHIP
        self.gate_1.survey_answers = SurveyAnswers(
            is_language_polyfill=True, explanation='something'
        )
        self.gate_1.put()

        params = {'state': Vote.NA_SELF}
        with test_app.test_request_context(self.request_path, json=params):
            actual = self.handler.do_post(
                feature_id=self.feature_id, gate_id=self.gate_1_id
            )

        self.assertEqual(actual, {'message': 'Done'})
        updated_votes = Vote.get_votes(feature_id=self.feature_id)
        self.assertEqual(1, len(updated_votes))
        vote = updated_votes[0]
        self.assertEqual(vote.feature_id, self.feature_id)
        self.assertEqual(vote.gate_id, 1)
        self.assertEqual(vote.set_by, 'owner1@example.com')
        self.assertEqual(vote.state, Vote.NA_SELF)

        mock_notifier.assert_called_once_with(
            self.feature_1,
            self.gate_1,
            'owner1@example.com',
            Vote.NA_SELF,
            Vote.NO_RESPONSE,
        )

    def test_check_voting_rules__not_verification(self):
        """We allow anything that is not NA_VERIFIED."""
        self.handler.check_voting_rules(self.gate_1, Vote.NO_RESPONSE)
        self.handler.check_voting_rules(self.gate_1, Vote.NA_SELF)
        self.handler.check_voting_rules(self.gate_1, Vote.APPROVED)
        self.handler.check_voting_rules(self.gate_1, Vote.REVIEW_REQUESTED)
        self.handler.check_voting_rules(self.gate_1, Vote.DENIED)
        self.handler.check_voting_rules(self.gate_1, Vote.NEEDS_WORK)

    def test_check_voting_rules__verification(self):
        """A NA_VERIFIED is only accepted if there was a NA_SELF."""
        self.vote_1_1.put()
        with self.assertRaises(werkzeug.exceptions.BadRequest):
            self.handler.check_voting_rules(self.gate_1, Vote.NA_VERIFIED)

        self.vote_1_1.state = Vote.NA_SELF
        self.vote_1_1.put()
        self.handler.check_voting_rules(self.gate_1, Vote.NA_VERIFIED)


class GatesAPITest(testing_config.CustomTestCase):
    """Tests for GatesAPI."""

    def setUp(self):
        """Set up the test environment."""
        self.feature_1 = core_models.FeatureEntry(
            name='feature one',
            summary='sum',
            category=1,
            owner_emails=['owner1@example.com'],
        )
        self.feature_1.put()
        self.feature_id = self.feature_1.key.integer_id()

        self.gate_1 = Gate(
            id=1,
            feature_id=self.feature_id,
            stage_id=1,
            gate_type=1,
            state=Vote.NA,
        )
        self.gate_1.put()
        self.gate_1_id = self.gate_1.key.integer_id()

        self.handler = reviews_api.GatesAPI()
        self.request_path = '/api/v0/features/%d/gates' % self.feature_id

    @mock.patch('internals.approval_defs.get_approvers')
    def test_do_get__success(self, mock_get_approvers):
        """Handler retrieves all gates associated with a given feature."""
        mock_get_approvers.return_value = ['reviewer1@example.com']

        with test_app.test_request_context(self.request_path):
            actual = self.handler.do_get(feature_id=self.feature_id)

        expected = {
            'gates': [
                {
                    'id': 1,
                    'feature_id': self.feature_id,
                    'stage_id': 1,
                    'gate_type': 1,
                    'team_name': 'API Owners',
                    'gate_name': 'Intent to Prototype',
                    'escalation_email': None,
                    'state': 1,
                    'requested_on': None,
                    'responded_on': None,
                    'assignee_emails': [],
                    'next_action': None,
                    'additional_review': False,
                    'self_certify_eligible': False,
                    'self_certify_possible': False,
                    'slo_initial_response': 5,
                    'slo_initial_response_took': None,
                    'slo_initial_response_remaining': None,
                    'slo_resolve': 10,
                    'slo_resolve_took': None,
                    'slo_resolve_remaining': None,
                    'needs_work_started_on': None,
                    'possible_assignee_emails': ['reviewer1@example.com'],
                    'survey_answers': None,
                },
            ],
        }

        self.maxDiff = None
        self.assertEqual(actual, expected)

    @mock.patch('internals.approval_defs.get_approvers')
    def test_do_get__empty_gates(self, mock_get_approvers):
        """Handler cannnot find any gates."""
        mock_get_approvers.return_value = ['reviewer1@example.com']
        gateless_feature = core_models.FeatureEntry(
            name='gateless feature',
            summary='sum',
            category=1,
            owner_emails=['owner1@example.com'],
        )
        gateless_feature.put()
        gateless_feature_id = gateless_feature.key.integer_id()

        with test_app.test_request_context(self.request_path):
            actual = self.handler.do_get(feature_id=gateless_feature_id)

        expected = {
            'gates': [],
        }
        self.assertEqual(actual, expected)

    def test_do_get__deleted(self):
        """If a feature is deleted, don't return any gates."""
        self.feature_1.deleted = True
        self.feature_1.put()

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


class XfnGatesAPITest(testing_config.CustomTestCase):
    """Tests for XfnGatesAPI."""

    def setUp(self):
        """Set up the test environment."""
        self.feature_1 = core_models.FeatureEntry(
            name='feature one',
            summary='sum',
            category=1,
            owner_emails=['owner1@example.com'],
        )
        self.feature_1.put()
        self.feature_id = self.feature_1.key.integer_id()

        self.stage_1 = core_models.Stage(
            feature_id=self.feature_id,
            stage_type=core_enums.STAGE_BLINK_SHIPPING,
        )
        self.stage_1.put()
        self.stage_id = self.stage_1.key.integer_id()

        self.gate_1 = Gate(
            id=1,
            feature_id=self.feature_id,
            stage_id=self.stage_id,
            gate_type=core_enums.GATE_API_SHIP,
            state=Vote.NA,
        )
        self.gate_1.put()
        self.gate_1_id = self.gate_1.key.integer_id()

        self.handler = reviews_api.XfnGatesAPI()
        self.request_path = '/api/v0/features/%d/stages/%d/addXfnGates' % (
            self.feature_id,
            self.stage_id,
        )

    def test_get(self):
        """We reject all GETs to this endpoint."""
        with test_app.test_request_context(self.request_path):
            with self.assertRaises(werkzeug.exceptions.MethodNotAllowed):
                self.handler.do_get()

    def test_do_post__not_found(self):
        """Handler rejects bad requests."""
        with test_app.test_request_context(self.request_path):
            with self.assertRaises(werkzeug.exceptions.NotFound):
                self.handler.do_post(
                    feature_id=self.feature_id + 1, stage_id=self.stage_id
                )

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

    def test_do_post__not_allowed(self):
        """Handler rejects users who lack permission."""
        testing_config.sign_out()
        with test_app.test_request_context(self.request_path):
            with self.assertRaises(werkzeug.exceptions.Forbidden):
                self.handler.do_post(
                    feature_id=self.feature_id, stage_id=self.stage_id
                )

        testing_config.sign_in('other@example.com', 999)
        with test_app.test_request_context(self.request_path):
            with self.assertRaises(werkzeug.exceptions.Forbidden):
                self.handler.do_post(
                    feature_id=self.feature_id, stage_id=self.stage_id
                )

    @mock.patch('api.reviews_api.XfnGatesAPI.create_xfn_gates')
    def test_do_post__editors_allowed(self, mock_create):
        """Handler accepts users who can edit the feature."""
        testing_config.sign_in('owner1@example.com', 123567890)
        mock_create.return_value = 111
        with test_app.test_request_context(self.request_path):
            actual = self.handler.do_post(
                feature_id=self.feature_id, stage_id=self.stage_id
            )

        mock_create.assert_called_once_with(self.feature_id, self.stage_id)
        self.assertEqual(actual, {'message': 'Created 111 gates'})

    @mock.patch('api.reviews_api.XfnGatesAPI.create_xfn_gates')
    def test_do_post__reviewers_allowed(self, mock_create):
        """Handler accepts users who can review any gate."""
        testing_config.sign_in(approval_defs.ENTERPRISE_APPROVERS[0], 123567890)
        mock_create.return_value = 222
        with test_app.test_request_context(self.request_path):
            actual = self.handler.do_post(
                feature_id=self.feature_id, stage_id=self.stage_id
            )

        mock_create.assert_called_once_with(self.feature_id, self.stage_id)
        self.assertEqual(actual, {'message': 'Created 222 gates'})

    def test_get_needed_gate_types(self):
        """We always assume that we are adding all gates for core_enums.STAGE_BLINK_SHIPPING."""  # noqa: E501
        actual = self.handler.get_needed_gate_types()
        self.assertEqual(actual, ALL_SHIPPING_GATE_TYPES)

    def test_create_xfn_gates__normal(self):
        """We can create the missing gates from core_enums.STAGE_BLINK_SHIPPING."""
        actual = self.handler.create_xfn_gates(self.feature_id, self.stage_id)

        self.assertEqual(actual, 6)
        actual_gates_dict = Gate.get_feature_gates(self.feature_id)
        self.assertCountEqual(actual_gates_dict.keys(), ALL_SHIPPING_GATE_TYPES)
