Repository: cisagov/manage.get.gov

Issue:
Implement/update the FSM to match the status diagram
### Issue description

When the Django UI was updated to reflect the status transition logic, we noticed some gaps in what statuses were available to select vs. what an analyst would need to select. e.g. currently if an application is in "action needed" status, an analyst can only change that status to "submitted." In reality, an analyst would likely want to change this back to "in review" or even immediately "approve" or "reject."

We revisited and updated our state transitions on domain applications, as reflected in the "Request Status in Django" frame of the [Status Diagram](https://miro.com/app/board/uXjVMuqbLOk=/?moveToWidget=3458764561795634398&cot=14). 

The status diagram above was updated 12/12/23 in conference. The changes are reflected in this image.
![image](https://github.com/cisagov/manage.get.gov/assets/62573986/cb3d7383-a6ac-44df-872c-052925f75bbd)

### Acceptance criteria

- [ ] for each status, the possible status transitions are updated to match the table above.
- [ ] unit tests added/updated where appropriate

### Additional context

[See this Slack conversation where the discussion originated.](https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1694553261479959)

See Product Huddle notes bullet point ['Review application statuses as they appear in Django admin' from Sept 13th](https://docs.google.com/document/d/1jatolB9YaQLITwqx25y0B1u5J23S_J19PawXtIaov8o/edit#heading=h.eqc8iaczwjdi).


### Links to other issues

⛔️  blocked by #1021


Discussion:

Resolving patch:
diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py
--- a/src/registrar/models/domain_application.py
+++ b/src/registrar/models/domain_application.py
@@ -585,7 +585,12 @@ def _send_status_update_email(self, new_status, email_template, email_template_s
 
     @transition(
         field="status",
-        source=[ApplicationStatus.STARTED, ApplicationStatus.ACTION_NEEDED, ApplicationStatus.WITHDRAWN],
+        source=[
+            ApplicationStatus.STARTED,
+            ApplicationStatus.IN_REVIEW,
+            ApplicationStatus.ACTION_NEEDED,
+            ApplicationStatus.WITHDRAWN,
+        ],
         target=ApplicationStatus.SUBMITTED,
     )
     def submit(self):
@@ -613,7 +618,17 @@ def submit(self):
             "emails/submission_confirmation_subject.txt",
         )
 
-    @transition(field="status", source=ApplicationStatus.SUBMITTED, target=ApplicationStatus.IN_REVIEW)
+    @transition(
+        field="status",
+        source=[
+            ApplicationStatus.SUBMITTED,
+            ApplicationStatus.ACTION_NEEDED,
+            ApplicationStatus.APPROVED,
+            ApplicationStatus.REJECTED,
+            ApplicationStatus.INELIGIBLE,
+        ],
+        target=ApplicationStatus.IN_REVIEW,
+    )
     def in_review(self):
         """Investigate an application that has been submitted.
 
@@ -627,7 +642,12 @@ def in_review(self):
 
     @transition(
         field="status",
-        source=[ApplicationStatus.IN_REVIEW, ApplicationStatus.REJECTED],
+        source=[
+            ApplicationStatus.IN_REVIEW,
+            ApplicationStatus.APPROVED,
+            ApplicationStatus.REJECTED,
+            ApplicationStatus.INELIGIBLE,
+        ],
         target=ApplicationStatus.ACTION_NEEDED,
     )
     def action_needed(self):
@@ -646,8 +666,8 @@ def action_needed(self):
         source=[
             ApplicationStatus.SUBMITTED,
             ApplicationStatus.IN_REVIEW,
+            ApplicationStatus.ACTION_NEEDED,
             ApplicationStatus.REJECTED,
-            ApplicationStatus.INELIGIBLE,
         ],
         target=ApplicationStatus.APPROVED,
     )
@@ -684,7 +704,7 @@ def approve(self):
 
     @transition(
         field="status",
-        source=[ApplicationStatus.SUBMITTED, ApplicationStatus.IN_REVIEW],
+        source=[ApplicationStatus.SUBMITTED, ApplicationStatus.IN_REVIEW, ApplicationStatus.ACTION_NEEDED],
         target=ApplicationStatus.WITHDRAWN,
     )
     def withdraw(self):
@@ -697,7 +717,7 @@ def withdraw(self):
 
     @transition(
         field="status",
-        source=[ApplicationStatus.IN_REVIEW, ApplicationStatus.APPROVED],
+        source=[ApplicationStatus.IN_REVIEW, ApplicationStatus.ACTION_NEEDED, ApplicationStatus.APPROVED],
         target=ApplicationStatus.REJECTED,
         conditions=[domain_is_not_active],
     )
@@ -707,12 +727,16 @@ def reject(self):
         As side effects this will delete the domain and domain_information
         (will cascade), and send an email notification."""
         if self.status == self.ApplicationStatus.APPROVED:
-            domain_state = self.approved_domain.state
-            # Only reject if it exists on EPP
-            if domain_state != Domain.State.UNKNOWN:
-                self.approved_domain.deletedInEpp()
-            self.approved_domain.delete()
-            self.approved_domain = None
+            try:
+                domain_state = self.approved_domain.state
+                # Only reject if it exists on EPP
+                if domain_state != Domain.State.UNKNOWN:
+                    self.approved_domain.deletedInEpp()
+                self.approved_domain.delete()
+                self.approved_domain = None
+            except Exception as err:
+                logger.error(err)
+                logger.error("Can't query an approved domain while attempting a DA reject()")
 
         self._send_status_update_email(
             "action needed",
@@ -722,7 +746,12 @@ def reject(self):
 
     @transition(
         field="status",
-        source=[ApplicationStatus.IN_REVIEW, ApplicationStatus.APPROVED],
+        source=[
+            ApplicationStatus.IN_REVIEW,
+            ApplicationStatus.ACTION_NEEDED,
+            ApplicationStatus.APPROVED,
+            ApplicationStatus.REJECTED,
+        ],
         target=ApplicationStatus.INELIGIBLE,
         conditions=[domain_is_not_active],
     )
@@ -736,12 +765,16 @@ def reject_with_prejudice(self):
         and domain_information (will cascade) when they exist."""
 
         if self.status == self.ApplicationStatus.APPROVED:
-            domain_state = self.approved_domain.state
-            # Only reject if it exists on EPP
-            if domain_state != Domain.State.UNKNOWN:
-                self.approved_domain.deletedInEpp()
-            self.approved_domain.delete()
-            self.approved_domain = None
+            try:
+                domain_state = self.approved_domain.state
+                # Only reject if it exists on EPP
+                if domain_state != Domain.State.UNKNOWN:
+                    self.approved_domain.deletedInEpp()
+                self.approved_domain.delete()
+                self.approved_domain = None
+            except Exception as err:
+                logger.error(err)
+                logger.error("Can't query an approved domain while attempting a DA reject_with_prejudice()")
 
         self.creator.restrict_user()
 


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
@@ -462,7 +462,7 @@ def completed_application(
 ):
     """A completed domain application."""
     if not user:
-        user = get_user_model().objects.create(username="username")
+        user = get_user_model().objects.create(username="username" + str(uuid.uuid4())[:8])
     ao, _ = Contact.objects.get_or_create(
         first_name="Testy",
         last_name="Tester",
diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py
--- a/src/registrar/tests/test_models.py
+++ b/src/registrar/tests/test_models.py
@@ -26,20 +26,52 @@
 # with AWS SES, so mock that out in all of these test cases
 @boto3_mocking.patching
 class TestDomainApplication(TestCase):
+    def setUp(self):
+        self.started_application = completed_application(
+            status=DomainApplication.ApplicationStatus.STARTED, name="started.gov"
+        )
+        self.submitted_application = completed_application(
+            status=DomainApplication.ApplicationStatus.SUBMITTED, name="submitted.gov"
+        )
+        self.in_review_application = completed_application(
+            status=DomainApplication.ApplicationStatus.IN_REVIEW, name="in-review.gov"
+        )
+        self.action_needed_application = completed_application(
+            status=DomainApplication.ApplicationStatus.ACTION_NEEDED, name="action-needed.gov"
+        )
+        self.approved_application = completed_application(
+            status=DomainApplication.ApplicationStatus.APPROVED, name="approved.gov"
+        )
+        self.withdrawn_application = completed_application(
+            status=DomainApplication.ApplicationStatus.WITHDRAWN, name="withdrawn.gov"
+        )
+        self.rejected_application = completed_application(
+            status=DomainApplication.ApplicationStatus.REJECTED, name="rejected.gov"
+        )
+        self.ineligible_application = completed_application(
+            status=DomainApplication.ApplicationStatus.INELIGIBLE, name="ineligible.gov"
+        )
+
+    def assertNotRaises(self, exception_type):
+        """Helper method for testing allowed transitions."""
+        return self.assertRaises(Exception, None, exception_type)
+
     def test_empty_create_fails(self):
-        """Can't create a completely empty domain application."""
+        """Can't create a completely empty domain application.
+        NOTE: something about theexception this test raises messes up with the
+        atomic block in a custom tearDown method for the parent test class."""
         with self.assertRaisesRegex(IntegrityError, "creator"):
             DomainApplication.objects.create()
 
     def test_minimal_create(self):
         """Can create with just a creator."""
-        user, _ = User.objects.get_or_create()
+        user, _ = User.objects.get_or_create(username="testy")
         application = DomainApplication.objects.create(creator=user)
         self.assertEqual(application.status, DomainApplication.ApplicationStatus.STARTED)
 
     def test_full_create(self):
         """Can create with all fields."""
-        user, _ = User.objects.get_or_create()
+        user, _ = User.objects.get_or_create(username="testy")
         contact = Contact.objects.create()
         com_website, _ = Website.objects.get_or_create(website="igorville.com")
         gov_website, _ = Website.objects.get_or_create(website="igorville.gov")
@@ -69,7 +101,7 @@ def test_full_create(self):
 
     def test_domain_info(self):
         """Can create domain info with all fields."""
-        user, _ = User.objects.get_or_create()
+        user, _ = User.objects.get_or_create(username="testy")
         contact = Contact.objects.create()
         domain, _ = Domain.objects.get_or_create(name="igorville.gov")
         information = DomainInformation.objects.create(
@@ -95,14 +127,14 @@ def test_domain_info(self):
         self.assertEqual(information.id, domain.domain_info.id)
 
     def test_status_fsm_submit_fail(self):
-        user, _ = User.objects.get_or_create()
+        user, _ = User.objects.get_or_create(username="testy")
         application = DomainApplication.objects.create(creator=user)
         with self.assertRaises(ValueError):
             # can't submit an application with a null domain name
             application.submit()
 
     def test_status_fsm_submit_succeed(self):
-        user, _ = User.objects.get_or_create()
+        user, _ = User.objects.get_or_create(username="testy")
         site = DraftDomain.objects.create(name="igorville.gov")
         application = DomainApplication.objects.create(creator=user, requested_domain=site)
         # no submitter email so this emits a log warning
@@ -112,7 +144,7 @@ def test_status_fsm_submit_succeed(self):
 
     def test_submit_sends_email(self):
         """Create an application and submit it and see if email was sent."""
-        user, _ = User.objects.get_or_create()
+        user, _ = User.objects.get_or_create(username="testy")
         contact = Contact.objects.create(email="test@test.gov")
         domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
         application = DomainApplication.objects.create(
@@ -135,320 +167,251 @@ def test_submit_sends_email(self):
             0,
         )
 
-    def test_transition_not_allowed_submitted_submitted(self):
-        """Create an application with status submitted and call submit
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.SUBMITTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.submit()
-
-    def test_transition_not_allowed_in_review_submitted(self):
-        """Create an application with status in review and call submit
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.submit()
-
-    def test_transition_not_allowed_approved_submitted(self):
-        """Create an application with status approved and call submit
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.submit()
-
-    def test_transition_not_allowed_rejected_submitted(self):
-        """Create an application with status rejected and call submit
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.submit()
-
-    def test_transition_not_allowed_ineligible_submitted(self):
-        """Create an application with status ineligible and call submit
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.INELIGIBLE)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.submit()
-
-    def test_transition_not_allowed_started_in_review(self):
-        """Create an application with status started and call in_review
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.STARTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.in_review()
-
-    def test_transition_not_allowed_in_review_in_review(self):
-        """Create an application with status in review and call in_review
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.in_review()
-
-    def test_transition_not_allowed_approved_in_review(self):
-        """Create an application with status approved and call in_review
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.in_review()
-
-    def test_transition_not_allowed_action_needed_in_review(self):
-        """Create an application with status action needed and call in_review
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.ACTION_NEEDED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.in_review()
-
-    def test_transition_not_allowed_rejected_in_review(self):
-        """Create an application with status rejected and call in_review
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.in_review()
-
-    def test_transition_not_allowed_withdrawn_in_review(self):
-        """Create an application with status withdrawn and call in_review
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.WITHDRAWN)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.in_review()
-
-    def test_transition_not_allowed_ineligible_in_review(self):
-        """Create an application with status ineligible and call in_review
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.INELIGIBLE)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.in_review()
-
-    def test_transition_not_allowed_started_action_needed(self):
-        """Create an application with status started and call action_needed
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.STARTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.action_needed()
-
-    def test_transition_not_allowed_submitted_action_needed(self):
-        """Create an application with status submitted and call action_needed
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.SUBMITTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.action_needed()
-
-    def test_transition_not_allowed_action_needed_action_needed(self):
-        """Create an application with status action needed and call action_needed
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.ACTION_NEEDED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.action_needed()
-
-    def test_transition_not_allowed_approved_action_needed(self):
-        """Create an application with status approved and call action_needed
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.action_needed()
-
-    def test_transition_not_allowed_withdrawn_action_needed(self):
-        """Create an application with status withdrawn and call action_needed
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.WITHDRAWN)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.action_needed()
-
-    def test_transition_not_allowed_ineligible_action_needed(self):
-        """Create an application with status ineligible and call action_needed
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.INELIGIBLE)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.action_needed()
-
-    def test_transition_not_allowed_started_approved(self):
-        """Create an application with status started and call approve
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.STARTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.approve()
-
-    def test_transition_not_allowed_approved_approved(self):
-        """Create an application with status approved and call approve
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.approve()
-
-    def test_transition_not_allowed_action_needed_approved(self):
-        """Create an application with status action needed and call approve
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.ACTION_NEEDED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.approve()
-
-    def test_transition_not_allowed_withdrawn_approved(self):
-        """Create an application with status withdrawn and call approve
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.WITHDRAWN)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.approve()
-
-    def test_transition_not_allowed_started_withdrawn(self):
-        """Create an application with status started and call withdraw
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.STARTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.withdraw()
-
-    def test_transition_not_allowed_approved_withdrawn(self):
-        """Create an application with status approved and call withdraw
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.withdraw()
-
-    def test_transition_not_allowed_action_needed_withdrawn(self):
-        """Create an application with status action needed and call withdraw
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.ACTION_NEEDED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.withdraw()
-
-    def test_transition_not_allowed_rejected_withdrawn(self):
-        """Create an application with status rejected and call withdraw
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.withdraw()
-
-    def test_transition_not_allowed_withdrawn_withdrawn(self):
-        """Create an application with status withdrawn and call withdraw
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.WITHDRAWN)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.withdraw()
-
-    def test_transition_not_allowed_ineligible_withdrawn(self):
-        """Create an application with status ineligible and call withdraw
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.INELIGIBLE)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.withdraw()
-
-    def test_transition_not_allowed_started_rejected(self):
-        """Create an application with status started and call reject
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.STARTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.reject()
-
-    def test_transition_not_allowed_submitted_rejected(self):
-        """Create an application with status submitted and call reject
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.SUBMITTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.reject()
-
-    def test_transition_not_allowed_action_needed_rejected(self):
-        """Create an application with status action needed and call reject
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.ACTION_NEEDED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.reject()
-
-    def test_transition_not_allowed_withdrawn_rejected(self):
-        """Create an application with status withdrawn and call reject
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.WITHDRAWN)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.reject()
-
-    def test_transition_not_allowed_rejected_rejected(self):
-        """Create an application with status rejected and call reject
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.reject()
-
-    def test_transition_not_allowed_ineligible_rejected(self):
-        """Create an application with status ineligible and call reject
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.INELIGIBLE)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.reject()
+    def test_submit_transition_allowed(self):
+        """
+        Test that calling submit from allowable statuses does raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.started_application, TransitionNotAllowed),
+            (self.in_review_application, TransitionNotAllowed),
+            (self.action_needed_application, TransitionNotAllowed),
+            (self.withdrawn_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                try:
+                    application.submit()
+                except TransitionNotAllowed:
+                    self.fail("TransitionNotAllowed was raised, but it was not expected.")
+
+    def test_submit_transition_not_allowed(self):
+        """
+        Test that calling submit against transition rules raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.submitted_application, TransitionNotAllowed),
+            (self.approved_application, TransitionNotAllowed),
+            (self.rejected_application, TransitionNotAllowed),
+            (self.ineligible_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                with self.assertRaises(exception_type):
+                    application.submit()
+
+    def test_in_review_transition_allowed(self):
+        """
+        Test that calling in_review from allowable statuses does raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.submitted_application, TransitionNotAllowed),
+            (self.action_needed_application, TransitionNotAllowed),
+            (self.approved_application, TransitionNotAllowed),
+            (self.rejected_application, TransitionNotAllowed),
+            (self.ineligible_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                try:
+                    application.in_review()
+                except TransitionNotAllowed:
+                    self.fail("TransitionNotAllowed was raised, but it was not expected.")
+
+    def test_in_review_transition_not_allowed(self):
+        """
+        Test that calling in_review against transition rules raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.started_application, TransitionNotAllowed),
+            (self.in_review_application, TransitionNotAllowed),
+            (self.withdrawn_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                with self.assertRaises(exception_type):
+                    application.in_review()
+
+    def test_action_needed_transition_allowed(self):
+        """
+        Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.in_review_application, TransitionNotAllowed),
+            (self.approved_application, TransitionNotAllowed),
+            (self.rejected_application, TransitionNotAllowed),
+            (self.ineligible_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                try:
+                    application.action_needed()
+                except TransitionNotAllowed:
+                    self.fail("TransitionNotAllowed was raised, but it was not expected.")
+
+    def test_action_needed_transition_not_allowed(self):
+        """
+        Test that calling action_needed against transition rules raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.started_application, TransitionNotAllowed),
+            (self.submitted_application, TransitionNotAllowed),
+            (self.action_needed_application, TransitionNotAllowed),
+            (self.withdrawn_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                with self.assertRaises(exception_type):
+                    application.action_needed()
+
+    def test_approved_transition_allowed(self):
+        """
+        Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.submitted_application, TransitionNotAllowed),
+            (self.in_review_application, TransitionNotAllowed),
+            (self.action_needed_application, TransitionNotAllowed),
+            (self.rejected_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                try:
+                    application.approve()
+                except TransitionNotAllowed:
+                    self.fail("TransitionNotAllowed was raised, but it was not expected.")
+
+    def test_approved_transition_not_allowed(self):
+        """
+        Test that calling action_needed against transition rules raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.started_application, TransitionNotAllowed),
+            (self.approved_application, TransitionNotAllowed),
+            (self.withdrawn_application, TransitionNotAllowed),
+            (self.ineligible_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                with self.assertRaises(exception_type):
+                    application.approve()
+
+    def test_withdraw_transition_allowed(self):
+        """
+        Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.submitted_application, TransitionNotAllowed),
+            (self.in_review_application, TransitionNotAllowed),
+            (self.action_needed_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                try:
+                    application.withdraw()
+                except TransitionNotAllowed:
+                    self.fail("TransitionNotAllowed was raised, but it was not expected.")
+
+    def test_withdraw_transition_not_allowed(self):
+        """
+        Test that calling action_needed against transition rules raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.started_application, TransitionNotAllowed),
+            (self.approved_application, TransitionNotAllowed),
+            (self.withdrawn_application, TransitionNotAllowed),
+            (self.rejected_application, TransitionNotAllowed),
+            (self.ineligible_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                with self.assertRaises(exception_type):
+                    application.withdraw()
+
+    def test_reject_transition_allowed(self):
+        """
+        Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.in_review_application, TransitionNotAllowed),
+            (self.action_needed_application, TransitionNotAllowed),
+            (self.approved_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                try:
+                    application.reject()
+                except TransitionNotAllowed:
+                    self.fail("TransitionNotAllowed was raised, but it was not expected.")
+
+    def test_reject_transition_not_allowed(self):
+        """
+        Test that calling action_needed against transition rules raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.started_application, TransitionNotAllowed),
+            (self.submitted_application, TransitionNotAllowed),
+            (self.withdrawn_application, TransitionNotAllowed),
+            (self.rejected_application, TransitionNotAllowed),
+            (self.ineligible_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                with self.assertRaises(exception_type):
+                    application.reject()
+
+    def test_reject_with_prejudice_transition_allowed(self):
+        """
+        Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.in_review_application, TransitionNotAllowed),
+            (self.action_needed_application, TransitionNotAllowed),
+            (self.approved_application, TransitionNotAllowed),
+            (self.rejected_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                try:
+                    application.reject_with_prejudice()
+                except TransitionNotAllowed:
+                    self.fail("TransitionNotAllowed was raised, but it was not expected.")
+
+    def test_reject_with_prejudice_transition_not_allowed(self):
+        """
+        Test that calling action_needed against transition rules raises TransitionNotAllowed.
+        """
+        test_cases = [
+            (self.started_application, TransitionNotAllowed),
+            (self.submitted_application, TransitionNotAllowed),
+            (self.withdrawn_application, TransitionNotAllowed),
+            (self.ineligible_application, TransitionNotAllowed),
+        ]
+
+        for application, exception_type in test_cases:
+            with self.subTest(application=application, exception_type=exception_type):
+                with self.assertRaises(exception_type):
+                    application.reject_with_prejudice()
 
     def test_transition_not_allowed_approved_rejected_when_domain_is_active(self):
         """Create an application with status approved, create a matching domain that
         is active, and call reject against transition rules"""
 
-        application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
-        domain = Domain.objects.create(name=application.requested_domain.name)
-        application.approved_domain = domain
-        application.save()
+        domain = Domain.objects.create(name=self.approved_application.requested_domain.name)
+        self.approved_application.approved_domain = domain
+        self.approved_application.save()
 
         # Define a custom implementation for is_active
         def custom_is_active(self):
@@ -458,70 +421,15 @@ def custom_is_active(self):
         with patch.object(Domain, "is_active", custom_is_active):
             # Now, when you call is_active on Domain, it will return True
             with self.assertRaises(TransitionNotAllowed):
-                application.reject()
-
-    def test_transition_not_allowed_started_ineligible(self):
-        """Create an application with status started and call reject
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.STARTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.reject_with_prejudice()
-
-    def test_transition_not_allowed_submitted_ineligible(self):
-        """Create an application with status submitted and call reject
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.SUBMITTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.reject_with_prejudice()
-
-    def test_transition_not_allowed_action_needed_ineligible(self):
-        """Create an application with status action needed and call reject
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.ACTION_NEEDED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.reject_with_prejudice()
-
-    def test_transition_not_allowed_withdrawn_ineligible(self):
-        """Create an application with status withdrawn and call reject
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.WITHDRAWN)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.reject_with_prejudice()
-
-    def test_transition_not_allowed_rejected_ineligible(self):
-        """Create an application with status rejected and call reject
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.reject_with_prejudice()
-
-    def test_transition_not_allowed_ineligible_ineligible(self):
-        """Create an application with status ineligible and call reject
-        against transition rules"""
-
-        application = completed_application(status=DomainApplication.ApplicationStatus.INELIGIBLE)
-
-        with self.assertRaises(TransitionNotAllowed):
-            application.reject_with_prejudice()
+                self.approved_application.reject()
 
     def test_transition_not_allowed_approved_ineligible_when_domain_is_active(self):
         """Create an application with status approved, create a matching domain that
         is active, and call reject_with_prejudice against transition rules"""
 
-        application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
-        domain = Domain.objects.create(name=application.requested_domain.name)
-        application.approved_domain = domain
-        application.save()
+        domain = Domain.objects.create(name=self.approved_application.requested_domain.name)
+        self.approved_application.approved_domain = domain
+        self.approved_application.save()
 
         # Define a custom implementation for is_active
         def custom_is_active(self):
@@ -531,7 +439,7 @@ def custom_is_active(self):
         with patch.object(Domain, "is_active", custom_is_active):
             # Now, when you call is_active on Domain, it will return True
             with self.assertRaises(TransitionNotAllowed):
-                application.reject_with_prejudice()
+                self.approved_application.reject_with_prejudice()
 
 
 class TestPermissions(TestCase):