diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index f92bb59ab..d1c533489 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -19,6 +19,9 @@ from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.tests.test_views import TestWithUser +from registrar.utility.email import EmailSendingError +from registrar.utility.email_invitations import send_portfolio_invitation_email +from registrar.utility.errors import MissingEmailError from .common import MockSESClient, completed_domain_request, create_test_user, create_user from waffle.testutils import override_flag from django.contrib.sessions.middleware import SessionMiddleware @@ -2531,7 +2534,7 @@ def setUpClass(cls): ], ) - cls.new_member_email = "new_user@example.com" + cls.new_member_email = "davekenn4242@gmail.com" AllowedEmail.objects.get_or_create(email=cls.new_member_email) @@ -2567,9 +2570,10 @@ def test_member_invite_for_new_users(self): session_id = self.client.session.session_key self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - mock_client = MagicMock() + mock_client_class = MagicMock() + mock_client = mock_client_class.return_value - with boto3_mocking.clients.handler_for("sesv2", mock_client): + with boto3_mocking.clients.handler_for("sesv2", mock_client_class): # Simulate submission of member invite for new user final_response = self.client.post( reverse("new-member"), @@ -2590,10 +2594,219 @@ def test_member_invite_for_new_users(self): self.assertIsNotNone(portfolio_invite) self.assertEqual(portfolio_invite.email, self.new_member_email) + # Check that an email was sent + self.assertTrue(mock_client.send_email.called) + + @boto3_mocking.patching + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_member_invite_for_new_users_initial_ajax_call_passes(self): + """Tests the member invitation flow for new users.""" + self.client.force_login(self.user) + + # Simulate a session to ensure continuity + session_id = self.client.session.session_key + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + mock_client_class = MagicMock() + mock_client = mock_client_class.return_value + + with boto3_mocking.clients.handler_for("sesv2", mock_client_class): + # Simulate submission of member invite for new user + final_response = self.client.post( + reverse("new-member"), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "email": self.new_member_email, + }, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + # Ensure the prep ajax submission is successful + self.assertEqual(final_response.status_code, 200) + + # Check that the response is a JSON response with is_valid + json_response = final_response.json() + self.assertIn("is_valid", json_response) + self.assertTrue(json_response["is_valid"]) + + # assert that portfolio invitation is not created + self.assertFalse( + PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(), + "Portfolio invitation should not be created when an Exception occurs." + ) + + # Check that an email was not sent + self.assertFalse(mock_client.send_email.called) + @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) - def test_member_invite_for_previously_invited_member(self): + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_member_invite_for_previously_invited_member_initial_ajax_call_fails(self, mock_send_email): + """Tests the initial ajax call in the member invitation flow for existing portfolio member.""" + self.client.force_login(self.user) + + # Simulate a session to ensure continuity + session_id = self.client.session.session_key + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + invite_count_before = PortfolioInvitation.objects.count() + + # Simulate submission of member invite for user who has already been invited + response = self.client.post( + reverse("new-member"), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "email": self.invited_member_email, + }, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + self.assertEqual(response.status_code, 200) + + # Check that the response is a JSON response with is_valid == False + json_response = response.json() + self.assertIn("is_valid", json_response) + self.assertFalse(json_response["is_valid"]) + + # Validate Database has not changed + invite_count_after = PortfolioInvitation.objects.count() + self.assertEqual(invite_count_after, invite_count_before) + + # assert that send_portfolio_invitation_email is not called + mock_send_email.assert_not_called() + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_submit_new_member_raises_email_sending_error(self, mock_send_email): + """Test when adding a new member and email_send method raises EmailSendingError.""" + mock_send_email.side_effect = EmailSendingError("Failed to send email.") + + self.client.force_login(self.user) + + # Simulate a session to ensure continuity + session_id = self.client.session.session_key + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + form_data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "email": self.new_member_email, + } + + # Act + with patch("django.contrib.messages.warning") as mock_warning: + response = self.client.post(reverse("new-member"), data=form_data) + + # Assert + # assert that the send_portfolio_invitation_email called + mock_send_email.assert_called_once_with( + email=self.new_member_email, requestor=self.user, portfolio=self.portfolio + ) + # assert that response is a redirect to reverse("members") + self.assertRedirects(response, reverse("members")) + # assert that messages contains message, "Could not send email invitation" + mock_warning.assert_called_once_with( + response.wsgi_request, "Could not send email invitation." + ) + # assert that portfolio invitation is not created + self.assertFalse( + PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(), + "Portfolio invitation should not be created when an EmailSendingError occurs." + ) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_submit_new_member_raises_missing_email_error(self, mock_send_email): + """Test when adding a new member and email_send method raises MissingEmailError.""" + mock_send_email.side_effect = MissingEmailError(self.user.username) + + self.client.force_login(self.user) + + # Simulate a session to ensure continuity + session_id = self.client.session.session_key + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + form_data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "email": self.new_member_email, + } + + # Act + with patch("django.contrib.messages.error") as mock_error: + response = self.client.post(reverse("new-member"), data=form_data) + + # Assert + # assert that the send_portfolio_invitation_email called + mock_send_email.assert_called_once_with( + email=self.new_member_email, requestor=self.user, portfolio=self.portfolio + ) + # assert that response is a redirect to reverse("members") + self.assertRedirects(response, reverse("members")) + # assert that messages contains message, "Could not send email invitation" + mock_error.assert_called_once_with( + response.wsgi_request, "Can't send invitation email. No email is associated with the account for 'test_user'." + ) + # assert that portfolio invitation is not created + self.assertFalse( + PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(), + "Portfolio invitation should not be created when a MissingEmailError occurs." + ) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_submit_new_member_raises_exception(self, mock_send_email): + """Test when adding a new member and email_send method raises Exception.""" + mock_send_email.side_effect = Exception("Generic exception") + + self.client.force_login(self.user) + + # Simulate a session to ensure continuity + session_id = self.client.session.session_key + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + form_data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "email": self.new_member_email, + } + + # Act + with patch("django.contrib.messages.warning") as mock_warning: + response = self.client.post(reverse("new-member"), data=form_data) + + # Assert + # assert that the send_portfolio_invitation_email called + mock_send_email.assert_called_once_with( + email=self.new_member_email, requestor=self.user, portfolio=self.portfolio + ) + # assert that response is a redirect to reverse("members") + self.assertRedirects(response, reverse("members")) + # assert that messages contains message, "Could not send email invitation" + mock_warning.assert_called_once_with( + response.wsgi_request, "Could not send email invitation." + ) + # assert that portfolio invitation is not created + self.assertFalse( + PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(), + "Portfolio invitation should not be created when an Exception occurs." + ) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_member_invite_for_previously_invited_member(self, mock_send_email): """Tests the member invitation flow for existing portfolio member.""" self.client.force_login(self.user) @@ -2621,10 +2834,14 @@ def test_member_invite_for_previously_invited_member(self): invite_count_after = PortfolioInvitation.objects.count() self.assertEqual(invite_count_after, invite_count_before) + # assert that send_portfolio_invitation_email is not called + mock_send_email.assert_not_called() + @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) - def test_member_invite_for_existing_member(self): + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_member_invite_for_existing_member(self, mock_send_email): """Tests the member invitation flow for existing portfolio member.""" self.client.force_login(self.user) @@ -2652,6 +2869,9 @@ def test_member_invite_for_existing_member(self): invite_count_after = PortfolioInvitation.objects.count() self.assertEqual(invite_count_after, invite_count_before) + # assert that send_portfolio_invitation_email is not called + mock_send_email.assert_not_called() + class TestEditPortfolioMemberView(WebTest): """Tests for the edit member page on portfolios""" diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index f5c16e2ac..34e98969f 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -594,5 +594,5 @@ def _handle_exceptions(self, exception, portfolio, email): exc_info=True, ) else: - logger.warning("Could not send email invitation (Other Exception)", portfolio, exc_info=True) + logger.warning("Could not send email invitation (Other Exception)", exc_info=True) messages.warning(self.request, "Could not send email invitation.")