Repository: cisagov/manage.get.gov

Issue:
Clearing out Nameserver record should be the same as using the delete icon 
### Issue description

Clearing out Nameserver record should be the same as using the delete icon. Currently, it is not the same. Clearing nameserver record in row 1 and row 2 and saving results in the “at least two nameservers required” message. But deleting the rows using the trash icon and saving doesn’t result in the message.

See [slack thread](https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1707918276148259) for steps to reproduce issue and troubleshooting discussion.

![image](https://github.com/cisagov/manage.get.gov/assets/144387994/ec02a34e-4494-41b2-8f29-8e295f8a9923)


### Acceptance criteria

- [ ] Clearing out Nameserver record should be the same as using the delete icon. Keep in mind the delete icon goes away when there are not a min of 2 nameservers after the delete. 

### Additional context

_No response_

### Links to other issues

Related: https://github.com/cisagov/manage.get.gov/issues/1192


Discussion:
This ticket seems refined and looks good to me
Calling this ready.

Resolving patch:
diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py
--- a/src/registrar/forms/domain.py
+++ b/src/registrar/forms/domain.py
@@ -83,25 +83,34 @@ def clean(self):
         # after clean_fields.  it is used to determine form level errors.
         # is_valid is typically called from view during a post
         cleaned_data = super().clean()
+
         self.clean_empty_strings(cleaned_data)
+
         server = cleaned_data.get("server", "")
-        # remove ANY spaces in the server field
-        server = server.replace(" ", "")
-        # lowercase the server
-        server = server.lower()
+        server = server.replace(" ", "").lower()
         cleaned_data["server"] = server
-        ip = cleaned_data.get("ip", None)
-        # remove ANY spaces in the ip field
+
+        ip = cleaned_data.get("ip", "")
         ip = ip.replace(" ", "")
         cleaned_data["ip"] = ip
+
         domain = cleaned_data.get("domain", "")
 
         ip_list = self.extract_ip_list(ip)
 
-        # validate if the form has a server or an ip
+        # Capture the server_value
+        server_value = self.cleaned_data.get("server")
+
+        # Validate if the form has a server or an ip
         if (ip and ip_list) or server:
             self.validate_nameserver_ip_combo(domain, server, ip_list)
 
+        # Re-set the server value:
+        # add_error which is called on validate_nameserver_ip_combo will clean-up (delete) any invalid data.
+        # We need that data because we need to know the total server entries (even if invalid) in the formset
+        # clean method where we determine whether a blank first and/or second entry should throw a required error.
+        self.cleaned_data["server"] = server_value
+
         return cleaned_data
 
     def clean_empty_strings(self, cleaned_data):
@@ -149,6 +158,19 @@ def clean(self):
         """
         Check for duplicate entries in the formset.
         """
+
+        # Check if there are at least two valid servers
+        valid_servers_count = sum(
+            1 for form in self.forms if form.cleaned_data.get("server") and form.cleaned_data.get("server").strip()
+        )
+        if valid_servers_count >= 2:
+            # If there are, remove the "At least two name servers are required" error from each form
+            # This will allow for successful submissions when the first or second entries are blanked
+            # but there are enough entries total
+            for form in self.forms:
+                if form.errors.get("server") == ["At least two name servers are required."]:
+                    form.errors.pop("server")
+
         if any(self.errors):
             # Don't bother validating the formset unless each form is valid on its own
             return
@@ -156,10 +178,13 @@ def clean(self):
         data = []
         duplicates = []
 
-        for form in self.forms:
+        for index, form in enumerate(self.forms):
             if form.cleaned_data:
                 value = form.cleaned_data["server"]
-                if value in data:
+                # We need to make sure not to trigger the duplicate error in case the first and second nameservers
+                # are empty. If there are enough records in the formset, that error is an unecessary blocker.
+                # If there aren't, the required error will block the submit.
+                if value in data and not (form.cleaned_data.get("server", "").strip() == "" and index == 1):
                     form.add_error(
                         "server",
                         NameserverError(code=nsErrorCodes.DUPLICATE_HOST, nameserver=value),


Test patch:
diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py
--- a/src/registrar/tests/common.py
+++ b/src/registrar/tests/common.py
@@ -1152,6 +1152,18 @@ def dummyInfoContactResultData(
         ],
     )
 
+    infoDomainFourHosts = fakedEppObject(
+        "fournameserversDomain.gov",
+        cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
+        contacts=[],
+        hosts=[
+            "ns1.my-nameserver-1.com",
+            "ns1.my-nameserver-2.com",
+            "ns1.cats-are-superior3.com",
+            "ns1.explosive-chicken-nuggets.com",
+        ],
+    )
+
     infoDomainNoHost = fakedEppObject(
         "my-nameserver.gov",
         cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
@@ -1452,7 +1464,9 @@ def mockRenewDomainCommand(self, _request, cleaned):
                 )
 
     def mockInfoDomainCommands(self, _request, cleaned):
-        request_name = getattr(_request, "name", None)
+        request_name = getattr(_request, "name", None).lower()
+
+        print(request_name)
 
         # Define a dictionary to map request names to data and extension values
         request_mappings = {
@@ -1474,7 +1488,8 @@ def mockInfoDomainCommands(self, _request, cleaned):
             "nameserverwithip.gov": (self.infoDomainHasIP, None),
             "namerserversubdomain.gov": (self.infoDomainCheckHostIPCombo, None),
             "freeman.gov": (self.InfoDomainWithContacts, None),
-            "threenameserversDomain.gov": (self.infoDomainThreeHosts, None),
+            "threenameserversdomain.gov": (self.infoDomainThreeHosts, None),
+            "fournameserversdomain.gov": (self.infoDomainFourHosts, None),
             "defaultsecurity.gov": (self.InfoDomainWithDefaultSecurityContact, None),
             "adomain2.gov": (self.InfoDomainWithVerisignSecurityContact, None),
             "defaulttechnical.gov": (self.InfoDomainWithDefaultTechnicalContact, None),
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -5,7 +5,7 @@
 from django.urls import reverse
 from django.contrib.auth import get_user_model
 
-from .common import MockSESClient, create_user  # type: ignore
+from .common import MockEppLib, MockSESClient, create_user  # type: ignore
 from django_webtest import WebTest  # type: ignore
 import boto3_mocking  # type: ignore
 
@@ -71,11 +71,14 @@ def setUp(self):
         # that inherit this setUp
         self.domain_dnssec_none, _ = Domain.objects.get_or_create(name="dnssec-none.gov")
 
+        self.domain_with_four_nameservers, _ = Domain.objects.get_or_create(name="fournameserversDomain.gov")
+
         self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
 
         DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dsdata)
         DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_multdsdata)
         DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dnssec_none)
+        DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_with_four_nameservers)
         DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_with_ip)
         DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_just_nameserver)
         DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_on_hold)
@@ -98,6 +101,11 @@ def setUp(self):
             domain=self.domain_dnssec_none,
             role=UserDomainRole.Roles.MANAGER,
         )
+        UserDomainRole.objects.get_or_create(
+            user=self.user,
+            domain=self.domain_with_four_nameservers,
+            role=UserDomainRole.Roles.MANAGER,
+        )
         UserDomainRole.objects.get_or_create(
             user=self.user,
             domain=self.domain_with_ip,
@@ -727,7 +735,7 @@ def test_domain_invitation_flow(self):
         self.assertContains(home_page, self.domain.name)
 
 
-class TestDomainNameservers(TestDomainOverview):
+class TestDomainNameservers(TestDomainOverview, MockEppLib):
     def test_domain_nameservers(self):
         """Can load domain's nameservers page."""
         page = self.client.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
@@ -974,6 +982,117 @@ def test_domain_nameservers_form_submits_successfully(self):
         page = result.follow()
         self.assertContains(page, "The name servers for this domain have been updated")
 
+    def test_domain_nameservers_can_blank_out_first_or_second_one_if_enough_entries(self):
+        """Nameserver form submits successfully with 2 valid inputs, even if the first or
+        second entries are blanked out.
+
+        Uses self.app WebTest because we need to interact with forms.
+        """
+
+        nameserver1 = ""
+        nameserver2 = "ns2.igorville.gov"
+        nameserver3 = "ns3.igorville.gov"
+        valid_ip = ""
+        valid_ip_2 = "128.0.0.2"
+        valid_ip_3 = "128.0.0.3"
+        nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
+        session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+        nameservers_page.form["form-0-server"] = nameserver1
+        nameservers_page.form["form-0-ip"] = valid_ip
+        nameservers_page.form["form-1-server"] = nameserver2
+        nameservers_page.form["form-1-ip"] = valid_ip_2
+        nameservers_page.form["form-2-server"] = nameserver3
+        nameservers_page.form["form-2-ip"] = valid_ip_3
+        with less_console_noise():  # swallow log warning message
+            result = nameservers_page.form.submit()
+
+        # form submission was a successful post, response should be a 302
+        self.assertEqual(result.status_code, 302)
+        self.assertEqual(
+            result["Location"],
+            reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}),
+        )
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+        nameservers_page = result.follow()
+        self.assertContains(nameservers_page, "The name servers for this domain have been updated")
+
+        nameserver1 = "ns1.igorville.gov"
+        nameserver2 = ""
+        nameserver3 = "ns3.igorville.gov"
+        valid_ip = "128.0.0.1"
+        valid_ip_2 = ""
+        valid_ip_3 = "128.0.0.3"
+        session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+        nameservers_page.form["form-0-server"] = nameserver1
+        nameservers_page.form["form-0-ip"] = valid_ip
+        nameservers_page.form["form-1-server"] = nameserver2
+        nameservers_page.form["form-1-ip"] = valid_ip_2
+        nameservers_page.form["form-2-server"] = nameserver3
+        nameservers_page.form["form-2-ip"] = valid_ip_3
+        with less_console_noise():  # swallow log warning message
+            result = nameservers_page.form.submit()
+
+        # form submission was a successful post, response should be a 302
+        self.assertEqual(result.status_code, 302)
+        self.assertEqual(
+            result["Location"],
+            reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}),
+        )
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+        nameservers_page = result.follow()
+        self.assertContains(nameservers_page, "The name servers for this domain have been updated")
+
+    def test_domain_nameservers_can_blank_out_first_and_second_one_if_enough_entries(self):
+        """Nameserver form submits successfully with 2 valid inputs, even if the first and
+        second entries are blanked out.
+
+        Uses self.app WebTest because we need to interact with forms.
+        """
+
+        # We need to start with a domain with 4 nameservers otherwise the formset in the test environment
+        # will only have 3 forms
+        nameserver1 = ""
+        nameserver2 = ""
+        nameserver3 = "ns3.igorville.gov"
+        nameserver4 = "ns4.igorville.gov"
+        valid_ip = ""
+        valid_ip_2 = ""
+        valid_ip_3 = ""
+        valid_ip_4 = ""
+        nameservers_page = self.app.get(
+            reverse("domain-dns-nameservers", kwargs={"pk": self.domain_with_four_nameservers.id})
+        )
+
+        session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+        # Minimal check to ensure the form is loaded correctly
+        self.assertEqual(nameservers_page.form["form-0-server"].value, "ns1.my-nameserver-1.com")
+        self.assertEqual(nameservers_page.form["form-3-server"].value, "ns1.explosive-chicken-nuggets.com")
+
+        nameservers_page.form["form-0-server"] = nameserver1
+        nameservers_page.form["form-0-ip"] = valid_ip
+        nameservers_page.form["form-1-server"] = nameserver2
+        nameservers_page.form["form-1-ip"] = valid_ip_2
+        nameservers_page.form["form-2-server"] = nameserver3
+        nameservers_page.form["form-2-ip"] = valid_ip_3
+        nameservers_page.form["form-3-server"] = nameserver4
+        nameservers_page.form["form-3-ip"] = valid_ip_4
+        with less_console_noise():  # swallow log warning message
+            result = nameservers_page.form.submit()
+
+        # form submission was a successful post, response should be a 302
+        self.assertEqual(result.status_code, 302)
+        self.assertEqual(
+            result["Location"],
+            reverse("domain-dns-nameservers", kwargs={"pk": self.domain_with_four_nameservers.id}),
+        )
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+        nameservers_page = result.follow()
+        self.assertContains(nameservers_page, "The name servers for this domain have been updated")
+
     def test_domain_nameservers_form_invalid(self):
         """Nameserver form does not submit with invalid data.