Repository: companieshouse/accounts-association-api
Pull request #173: Idva6-2042 Data Migration Patch Association
Author: login: krishna-patel-ch
Created: 2025-03-19T14:45:25Z | Merged: 2025-03-27T09:32:32Z

__Short description outlining key changes/additions__

Updated patch endpoint so that it can work with migrated associations.

__JIRA Ticket Number__

IDVA6-2042

### Type of change

* [ ] Bug fix
* [x] New feature
* [ ] Breaking change
* [ ] Infrastructure change

### Pull request checklist

* [x] I have added unit tests for new code that I have added
* [x] I have added/updated functional tests where appropriate
* [x] The code follows our [coding standards](https://github.com/companieshouse/styleguides/blob/master/java.md)

__An exhaustive list of peer review checks can be read [here](https://github.com/companieshouse/styleguides/blob/master/java_review.md#developer-actions-prior-to-code-commitreview-started)__


Diff:
diff --git a/src/main/java/uk/gov/companieshouse/accounts/association/controller/UserCompanyAssociation.java b/src/main/java/uk/gov/companieshouse/accounts/association/controller/UserCompanyAssociation.java
index e9f9fb08..60d792ac 100644
--- a/src/main/java/uk/gov/companieshouse/accounts/association/controller/UserCompanyAssociation.java
+++ b/src/main/java/uk/gov/companieshouse/accounts/association/controller/UserCompanyAssociation.java
@@ -3,21 +3,24 @@
 import static uk.gov.companieshouse.accounts.association.models.Constants.ITEMS_PER_PAGE_WAS_LESS_THAN_OR_EQUAL_TO_0;
 import static uk.gov.companieshouse.accounts.association.models.Constants.PAGE_INDEX_WAS_LESS_THAN_0;
 import static uk.gov.companieshouse.accounts.association.models.Constants.PLEASE_CHECK_THE_REQUEST_AND_TRY_AGAIN;
+import static uk.gov.companieshouse.accounts.association.utils.AssociationsUtil.mapToConfirmedUpdate;
+import static uk.gov.companieshouse.accounts.association.utils.AssociationsUtil.mapToInvitationUpdate;
+import static uk.gov.companieshouse.accounts.association.utils.AssociationsUtil.mapToRemovedUpdate;
 import static uk.gov.companieshouse.accounts.association.utils.RequestContextUtil.getEricIdentity;
 import static uk.gov.companieshouse.accounts.association.utils.RequestContextUtil.getUser;
 import static uk.gov.companieshouse.accounts.association.utils.RequestContextUtil.getXRequestId;
+import static uk.gov.companieshouse.accounts.association.utils.UserUtil.mapToDisplayValue;
 import static uk.gov.companieshouse.api.accounts.associations.model.Association.StatusEnum.AWAITING_APPROVAL;
+import static uk.gov.companieshouse.api.accounts.associations.model.Association.StatusEnum.MIGRATED;
 import static uk.gov.companieshouse.api.accounts.associations.model.RequestBodyPut.StatusEnum.CONFIRMED;
 import static uk.gov.companieshouse.api.accounts.associations.model.RequestBodyPut.StatusEnum.REMOVED;
 import static uk.gov.companieshouse.accounts.association.utils.LoggingUtil.LOGGER;
 
 import jakarta.validation.Valid;
 import jakarta.validation.constraints.Pattern;
-import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
-import org.springframework.data.mongodb.core.query.Update;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.RestController;
@@ -34,6 +37,7 @@
 import uk.gov.companieshouse.accounts.association.utils.StaticPropertyUtil;
 import uk.gov.companieshouse.api.accounts.associations.api.UserCompanyAssociationInterface;
 import uk.gov.companieshouse.api.accounts.associations.model.Association;
+import uk.gov.companieshouse.api.accounts.associations.model.Association.StatusEnum;
 import uk.gov.companieshouse.api.accounts.associations.model.InvitationsList;
 import uk.gov.companieshouse.api.accounts.associations.model.PreviousStatesList;
 import uk.gov.companieshouse.api.accounts.associations.model.RequestBodyPut;
@@ -107,53 +111,47 @@ public ResponseEntity<PreviousStatesList> getPreviousStatesForAssociation(@Patte
         return null;
     }
 
-    private void throwBadRequestWhenUserIsNotPermittedToPerformAction( final String xRequestId, final String requestingUserId, final AssociationDao associationDao, final RequestBodyPut.StatusEnum newStatus, final boolean requestingAndTargetUserMatches ){
-        if ( !requestingAndTargetUserMatches && ( newStatus.equals( CONFIRMED ) || !associationsService.confirmedAssociationExists( associationDao.getCompanyNumber(), requestingUserId ) ) ) {
-            LOGGER.errorContext( xRequestId, new Exception( String.format( "Requesting %s user cannot change another user to confirmed or the requesting user is not associated with company %s", requestingUserId, associationDao.getCompanyNumber() ) ), null );
+    private void throwBadRequestWhenUserIsNotPermittedToPerformAction( final AssociationDao targetAssociation, final RequestBodyPut.StatusEnum newStatus, final boolean requestingAndTargetUserMatches ){
+        final var oldStatus = StatusEnum.fromValue( targetAssociation.getStatus() );
+        if ( MIGRATED.equals( oldStatus ) && requestingAndTargetUserMatches ){
+            if ( CONFIRMED.equals( newStatus ) ) {
+                LOGGER.errorContext( getXRequestId(), new Exception( "Requesting user cannot change their status from migrated to confirmed" ), null );
+                throw new BadRequestRuntimeException( "requesting user does not have access to perform the action" );
+            }
+        } else if ( !requestingAndTargetUserMatches && ( ( CONFIRMED.equals( newStatus ) && !MIGRATED.equals( oldStatus ) ) || !associationsService.confirmedAssociationExists( targetAssociation.getCompanyNumber(), getEricIdentity() ) ) ) {
+            LOGGER.errorContext( getXRequestId(), new Exception( String.format( "Requesting %s user cannot change another user to confirmed or the requesting user is not associated with company %s", getEricIdentity(), targetAssociation.getCompanyNumber() ) ), null );
             throw new BadRequestRuntimeException( "requesting user does not have access to perform the action" );
         }
     }
 
-    private String updateAssociationWithUserEmail( final String xRequestId, final RequestBodyPut.StatusEnum newStatus, final AssociationDao associationDao ){
-        final var targetUserEmail = associationDao.getUserEmail();
-        final var timestampKey = newStatus.equals( CONFIRMED ) ? "approved_at" : "removed_at";
-        final var update = new Update().set( "status", newStatus.getValue() ).set( timestampKey, LocalDateTime.now().toString() );
-
-        final var targetUserDisplayValue =
-                Optional.ofNullable( usersService.searchUserDetails( List.of( targetUserEmail ) ) )
-                        .flatMap( list -> list.stream().findFirst() )
-                        .map( user -> {
-                            update.set( "user_email", null ).set( "user_id", user.getUserId() );
-                            return Optional.ofNullable( user.getDisplayName() ).orElse( user.getEmail() );
-                        } )
-                        .orElseGet( () -> {
-                            if ( newStatus.equals( CONFIRMED ) ) {
-                                LOGGER.errorContext( xRequestId, new Exception( String.format( "Could not find user %s, so cannot change status to confirmed.", targetUserEmail ) ), null );
-                                throw new BadRequestRuntimeException( String.format( "Could not find data for user %s", targetUserEmail ) );
-                            }
-                            return targetUserEmail;
-                        } );
-
-        LOGGER.debugContext( xRequestId, String.format( "Attempting to update association with id: %s", associationDao.getId() ), null );
-        associationsService.updateAssociation( associationDao.getId(), update );
-
-        return targetUserDisplayValue;
+    private String updateAssociationWithUserEmail( final AssociationDao targetAssociation, final RequestBodyPut.StatusEnum newStatus ){
+        final var targetUser = Optional.ofNullable( usersService.searchUserDetails( List.of( targetAssociation.getUserEmail() ) ) )
+                .flatMap( list -> list.stream().findFirst() )
+                .orElse( null );
+
+        final var update = Optional.of( mapToInvitationUpdate( targetAssociation, targetUser, getEricIdentity() ) )
+                .filter( any -> Objects.isNull( targetUser ) && CONFIRMED.equals( newStatus ) )
+                .map( any -> {
+                    if ( !MIGRATED.equals( StatusEnum.fromValue( targetAssociation.getStatus() ) ) ){
+                        LOGGER.errorContext( getXRequestId(), new Exception( String.format( "Could not find user %s, so cannot change status to confirmed.", targetAssociation.getUserEmail() ) ), null );
+                        throw new BadRequestRuntimeException( String.format( "Could not find data for user %s", targetAssociation.getUserEmail() ) );
+                    }
+                    return any;
+                } )
+                .orElse( CONFIRMED.equals( newStatus ) ? mapToConfirmedUpdate( targetAssociation, targetUser, getEricIdentity() ) : mapToRemovedUpdate( targetAssociation, targetUser, getEricIdentity() ) );
+
+        LOGGER.debugContext( getXRequestId(), String.format( "Attempting to update association with id: %s", targetAssociation.getId() ), null );
+        associationsService.updateAssociation( targetAssociation.getId(), update );
+
+        return mapToDisplayValue( targetUser, targetAssociation.getUserEmail() );
     }
 
-    private String updateAssociationWithUserId( final RequestBodyPut.StatusEnum newStatus, final AssociationDao associationDao, final String requestingUserDisplayValue, final boolean requestingAndTargetUserMatches ){
-        final var timestampKey = newStatus.equals( CONFIRMED ) ? "approved_at" : "removed_at";
-        final var update = new Update().set( "status", newStatus.getValue() ).set( timestampKey, LocalDateTime.now().toString() );
-
-        var targetUserDisplayValue = requestingUserDisplayValue;
-        if( !requestingAndTargetUserMatches ){
-            final var tempUser = usersService.fetchUserDetails( associationDao.getUserId() );
-            targetUserDisplayValue = Optional.ofNullable( tempUser.getDisplayName() ).orElse( tempUser.getEmail() );
-        }
-
-        LOGGER.debugContext( getXRequestId(), String.format( "Attempting to update association with id: %s", associationDao.getId() ), null );
-        associationsService.updateAssociation(associationDao.getId(), update);
-
-        return targetUserDisplayValue;
+    private String updateAssociationWithUserId( final AssociationDao targetAssociation, final RequestBodyPut.StatusEnum newStatus, final boolean requestingAndTargetUserMatches ){
+        final var targetUser = requestingAndTargetUserMatches ? getUser() : usersService.fetchUserDetails( targetAssociation.getUserId() );
+        final var update = CONFIRMED.equals( newStatus ) ? mapToConfirmedUpdate( targetAssociation, targetUser, getEricIdentity() ) : mapToRemovedUpdate( targetAssociation, targetUser, getEricIdentity() );
+        LOGGER.debugContext( getXRequestId(), String.format( "Attempting to update association with id: %s", targetAssociation.getId() ), null );
+        associationsService.updateAssociation( targetAssociation.getId(), update );
+        return mapToDisplayValue( targetUser, null );
     }
 
     @Override
@@ -178,13 +176,13 @@ public ResponseEntity<Void> updateAssociationStatusForId( final String associati
         final boolean requestingAndTargetUserMatches = requestingAndTargetUserIdMatches || requestingAndTargetUserEmailMatches;
         final boolean associationWithUserEmailExists = Objects.nonNull( associationDao.getUserEmail() );
 
-        throwBadRequestWhenUserIsNotPermittedToPerformAction( getXRequestId(), getEricIdentity(), associationDao, newStatus, requestingAndTargetUserMatches );
+        throwBadRequestWhenUserIsNotPermittedToPerformAction( associationDao, newStatus, requestingAndTargetUserMatches );
 
         var targetUserDisplayValue = requestingUserDisplayValue;
         if ( associationWithUserEmailExists ) {
-            targetUserDisplayValue = updateAssociationWithUserEmail( getXRequestId(), newStatus, associationDao );
+            targetUserDisplayValue = updateAssociationWithUserEmail( associationDao, newStatus );
         } else {
-            targetUserDisplayValue = updateAssociationWithUserId( newStatus, associationDao, requestingUserDisplayValue, requestingAndTargetUserMatches );
+            targetUserDisplayValue = updateAssociationWithUserId( associationDao, newStatus, requestingAndTargetUserMatches );
         }
         LOGGER.infoContext( getXRequestId(), String.format( "Updated association %s", associationDao.getId() ), null );
 
diff --git a/src/main/java/uk/gov/companieshouse/accounts/association/utils/AssociationsUtil.java b/src/main/java/uk/gov/companieshouse/accounts/association/utils/AssociationsUtil.java
new file mode 100644
index 00000000..9e9c5594
--- /dev/null
+++ b/src/main/java/uk/gov/companieshouse/accounts/association/utils/AssociationsUtil.java
@@ -0,0 +1,54 @@
+package uk.gov.companieshouse.accounts.association.utils;
+
+import static uk.gov.companieshouse.GenerateEtagUtil.generateEtag;
+import static uk.gov.companieshouse.accounts.association.utils.StaticPropertyUtil.DAYS_SINCE_INVITE_TILL_EXPIRES;
+import static uk.gov.companieshouse.api.accounts.associations.model.PreviousState.StatusEnum.AWAITING_APPROVAL;
+import static uk.gov.companieshouse.api.accounts.associations.model.RequestBodyPut.StatusEnum.CONFIRMED;
+import static uk.gov.companieshouse.api.accounts.associations.model.RequestBodyPut.StatusEnum.REMOVED;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+import org.springframework.data.mongodb.core.query.Update;
+import uk.gov.companieshouse.accounts.association.models.AssociationDao;
+import uk.gov.companieshouse.accounts.association.models.InvitationDao;
+import uk.gov.companieshouse.accounts.association.models.PreviousStatesDao;
+import uk.gov.companieshouse.api.accounts.user.model.User;
+
+public final class AssociationsUtil {
+
+    private AssociationsUtil(){}
+
+    private static Update mapToBaseUpdate( final AssociationDao targetAssociation, final User targetUser, final String changedByUserId ){
+        final var previousState = new PreviousStatesDao().status( targetAssociation.getStatus() ).changedBy( changedByUserId ).changedAt( LocalDateTime.now() );
+
+        final var baseUpdate = new Update()
+                .set( "etag", generateEtag() )
+                .push( "previous_states", previousState );
+
+        return Optional.ofNullable( targetUser )
+                .map( user -> baseUpdate.set( "user_email", null ).set( "user_id", user.getUserId() ) )
+                .orElse( baseUpdate );
+    }
+
+    public static Update mapToInvitationUpdate( final AssociationDao targetAssociation, final User targetUser, final String invitedByUserId ){
+        final var currentTimestamp = LocalDateTime.now();
+        final var invitationDao = new InvitationDao().invitedBy( invitedByUserId ).invitedAt( currentTimestamp );
+        return mapToBaseUpdate( targetAssociation, targetUser, invitedByUserId )
+                .push( "invitations", invitationDao )
+                .set( "status", AWAITING_APPROVAL.getValue() )
+                .set( "approval_expiry_at", currentTimestamp.plusDays( DAYS_SINCE_INVITE_TILL_EXPIRES ) );
+    }
+
+    public static Update mapToConfirmedUpdate( final AssociationDao targetAssociation, final User targetUser, final String changedByUserId ){
+        return mapToBaseUpdate( targetAssociation, targetUser, changedByUserId )
+                .set( "status", CONFIRMED.getValue() )
+                .set( "approved_at", LocalDateTime.now() );
+    }
+
+    public static Update mapToRemovedUpdate( final AssociationDao targetAssociation, final User targetUser, final String changedByUserId ){
+        return mapToBaseUpdate( targetAssociation, targetUser, changedByUserId )
+                .set( "status", REMOVED.getValue() )
+                .set( "removed_at", LocalDateTime.now() );
+    }
+
+}
diff --git a/src/main/java/uk/gov/companieshouse/accounts/association/utils/UserUtil.java b/src/main/java/uk/gov/companieshouse/accounts/association/utils/UserUtil.java
new file mode 100644
index 00000000..d0bf4836
--- /dev/null
+++ b/src/main/java/uk/gov/companieshouse/accounts/association/utils/UserUtil.java
@@ -0,0 +1,16 @@
+package uk.gov.companieshouse.accounts.association.utils;
+
+import java.util.Optional;
+import uk.gov.companieshouse.api.accounts.user.model.User;
+
+public final class UserUtil {
+
+    private UserUtil(){}
+
+    public static String mapToDisplayValue( final User user, final String defaultValue ){
+        return Optional.ofNullable( user )
+                .map( userData -> Optional.ofNullable( userData.getDisplayName() ).orElse( userData.getEmail() ) )
+                .orElse( defaultValue );
+    }
+
+}
diff --git a/src/test/java/uk/gov/companieshouse/accounts/association/common/TestDataManager.java b/src/test/java/uk/gov/companieshouse/accounts/association/common/TestDataManager.java
index 16f35a82..d9552478 100644
--- a/src/test/java/uk/gov/companieshouse/accounts/association/common/TestDataManager.java
+++ b/src/test/java/uk/gov/companieshouse/accounts/association/common/TestDataManager.java
@@ -878,7 +878,7 @@ private void instantiateAssociationDaoSuppliers(){
         final Supplier<AssociationDao> marioAssociation = () -> new AssociationDao()
                 .id( "MKAssociation001" )
                 .companyNumber( "MKCOMP001" )
-                .userId( "MKUser001" )
+                .userEmail( "mario@mushroom.kingdom" )
                 .status( "migrated" )
                 .approvalRoute( "migration" )
                 .migratedAt( now.minusDays( 10L ) )
diff --git a/src/test/java/uk/gov/companieshouse/accounts/association/controller/UserCompanyAssociationTest.java b/src/test/java/uk/gov/companieshouse/accounts/association/controller/UserCompanyAssociationTest.java
index feed7630..7a0dcf47 100644
--- a/src/test/java/uk/gov/companieshouse/accounts/association/controller/UserCompanyAssociationTest.java
+++ b/src/test/java/uk/gov/companieshouse/accounts/association/controller/UserCompanyAssociationTest.java
@@ -14,10 +14,14 @@
 import java.util.List;
 import java.util.Optional;
 import java.util.function.Function;
+import java.util.stream.Stream;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 import org.mockito.Mockito;
 import org.mockito.internal.verification.Times;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -28,6 +32,7 @@
 import org.springframework.http.MediaType;
 import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
 import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultMatcher;
 import org.springframework.test.web.servlet.setup.MockMvcBuilders;
 import org.springframework.web.context.WebApplicationContext;
 import reactor.core.publisher.Mono;
@@ -692,4 +697,42 @@ void updateAssociationStatusForIdUserRejectedInvitationNotificationsSendsNotific
         Mockito.verify( emailService ).sendInvitationRejectedEmailToAssociatedUser( eq( "theId123" ), eq( "x222222" ), any( Mono.class ), eq( "Scrooge McDuck" ) );
     }
 
+    public static Stream<Arguments> updateAssociationStatusForIdMigratedScenarios(){
+        return Stream.of(
+                Arguments.of( "MKUser001", "confirmed", true, status().isBadRequest() ),
+                Arguments.of( "MKUser001", "removed", true, status().isOk() ),
+                Arguments.of( "MKUser002", "confirmed", true, status().isOk() ),
+                Arguments.of( "MKUser002", "removed", true, status().isOk() ),
+                Arguments.of( "MKUser002", "confirmed", false, status().isOk() ),
+                Arguments.of( "MKUser002", "removed", false, status().isOk() )
+
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource( "updateAssociationStatusForIdMigratedScenarios" )
+    void updateAssociationStatusForIdSupportsMigratedAssociation( final String requestingUserId, final String newStatus, final boolean targetUserExists, final ResultMatcher expectedOutcome ) throws Exception {
+        final var targetAssociation = testDataManager.fetchAssociationDaos( "MKAssociation001", "MKAssociation002" ).getFirst();
+
+        mockers.mockUsersServiceFetchUserDetails( requestingUserId );
+
+        if ( targetUserExists ) {
+            mockers.mockUsersServiceSearchUserDetails( "MKUser001" );
+        } else {
+            mockers.mockUsersServiceSearchUserDetailsEmptyList( "mario@mushroom.kingdom" );
+        }
+
+        mockers.mockCompanyServiceFetchCompanyProfile( "MKCOMP001" );
+        Mockito.doReturn( Optional.of( targetAssociation ) ).when( associationsService ).findAssociationDaoById( "MKAssociation001" );
+        Mockito.doReturn( true ).when( associationsService ).confirmedAssociationExists( any(), any() );
+
+        mockMvc.perform( patch( "/associations/MKAssociation001"  )
+                        .header( "X-Request-Id", "theId123" )
+                        .header( "Eric-identity", requestingUserId )
+                        .header( "ERIC-Identity-Type", "oauth2" )
+                        .contentType( MediaType.APPLICATION_JSON )
+                        .content( String.format( "{\"status\":\"%s\"}", newStatus ) ) )
+                .andExpect( expectedOutcome );
+    }
+
 }
diff --git a/src/test/java/uk/gov/companieshouse/accounts/association/integration/UserCompanyAssociationTest.java b/src/test/java/uk/gov/companieshouse/accounts/association/integration/UserCompanyAssociationTest.java
index 0c68ded0..08996341 100644
--- a/src/test/java/uk/gov/companieshouse/accounts/association/integration/UserCompanyAssociationTest.java
+++ b/src/test/java/uk/gov/companieshouse/accounts/association/integration/UserCompanyAssociationTest.java
@@ -357,6 +357,10 @@ void updateAssociationStatusForIdWithNullUserIdAndNonexistentUserAndRemovedUpdat
         Assertions.assertNotEquals( oldAssociationData.getEtag(), newAssociationData.getEtag() );
         Assertions.assertEquals( oldAssociationData.getUserEmail(), newAssociationData.getUserEmail() );
         Assertions.assertEquals( oldAssociationData.getUserId(), newAssociationData.getUserId() );
+        Assertions.assertEquals( 1, newAssociationData.getPreviousStates().size() );
+        Assertions.assertEquals( "awaiting-approval", newAssociationData.getPreviousStates().getFirst().getStatus() );
+        Assertions.assertEquals( "111", newAssociationData.getPreviousStates().getFirst().getChangedBy() );
+        Assertions.assertNotNull( newAssociationData.getPreviousStates().getFirst().getChangedAt() );
     }
 
     @Test
@@ -484,6 +488,165 @@ void updateAssociationStatusForIdUserAcceptedInvitationNotificationsSendsNotific
         Mockito.verify( emailProducer, times( 3 ) ).sendEmail( argThat( comparisonUtils.invitationAcceptedEmailDataMatcher( List.of( "the.joker@gotham.city", "robin@gotham.city", "homer.simpson@springfield.com" ), expectedBaseEmail ) ), eq( INVITATION_ACCEPTED_MESSAGE_TYPE.getValue() ) );
     }
 
+    @Test
+    void updateAssociationStatusForIdThrowsBadRequestWhenUserTriesToConfirmOwnMigratedAssociation() throws Exception {
+        associationsRepository.insert( testDataManager.fetchAssociationDaos( "MKAssociation001" ) );
+        mockers.mockCompanyServiceFetchCompanyProfile( "MKCOMP001" );
+        mockers.mockUsersServiceSearchUserDetails( "MKUser001" );
+        mockers.mockUsersServiceFetchUserDetails( "MKUser001" );
+
+        mockMvc.perform( patch( "/associations/MKAssociation001"  )
+                        .header( "X-Request-Id", "theId123" )
+                        .header( "Eric-identity", "MKUser001" )
+                        .header( "ERIC-Identity-Type", "oauth2" )
+                        .contentType( MediaType.APPLICATION_JSON )
+                        .content( "{\"status\":\"confirmed\"}" ) )
+                .andExpect( status().isBadRequest() );
+    }
+
+    @Test
+    void updateAssociationStatusForIdUpdatesAssociationWhenUserTriesToRemoveOwnMigratedAssociation() throws Exception {
+        associationsRepository.insert( testDataManager.fetchAssociationDaos( "MKAssociation001" ) );
+        mockers.mockCompanyServiceFetchCompanyProfile( "MKCOMP001" );
+        mockers.mockUsersServiceSearchUserDetails( "MKUser001" );
+        mockers.mockUsersServiceFetchUserDetails( "MKUser001" );
+
+        mockMvc.perform( patch( "/associations/MKAssociation001"  )
+                        .header( "X-Request-Id", "theId123" )
+                        .header( "Eric-identity", "MKUser001" )
+                        .header( "ERIC-Identity-Type", "oauth2" )
+                        .contentType( MediaType.APPLICATION_JSON )
+                        .content( "{\"status\":\"removed\"}" ) )
+                .andExpect( status().isOk() );
+
+        final var updatedAssociation = associationsRepository.findById( "MKAssociation001" ).get();
+
+        Assertions.assertEquals( "removed", updatedAssociation.getStatus() );
+        Assertions.assertNotNull( updatedAssociation.getRemovedAt() );
+        Assertions.assertNotNull( updatedAssociation.getEtag() );
+        Assertions.assertEquals( 1, updatedAssociation.getPreviousStates().size() );
+        Assertions.assertEquals( "migrated", updatedAssociation.getPreviousStates().getFirst().getStatus() );
+        Assertions.assertEquals( "MKUser001", updatedAssociation.getPreviousStates().getFirst().getChangedBy() );
+        Assertions.assertNotNull(  updatedAssociation.getPreviousStates().getFirst().getChangedAt() );
+        Assertions.assertNull( updatedAssociation.getUserEmail() );
+        Assertions.assertEquals( "MKUser001", updatedAssociation.getUserId() );
+    }
+
+    @Test
+    void updateAssociationStatusForIdUpdatesAssociationWhenUserTriesToConfirmedAnotherUsersMigratedAssociation() throws Exception {
+        associationsRepository.insert( testDataManager.fetchAssociationDaos( "MKAssociation001", "MKAssociation002" ) );
+        mockers.mockCompanyServiceFetchCompanyProfile( "MKCOMP001" );
+        mockers.mockUsersServiceSearchUserDetails( "MKUser001" );
+        mockers.mockUsersServiceFetchUserDetails( "MKUser002" );
+
+        mockMvc.perform( patch( "/associations/MKAssociation001"  )
+                        .header( "X-Request-Id", "theId123" )
+                        .header( "Eric-identity", "MKUser002" )
+                        .header( "ERIC-Identity-Type", "oauth2" )
+                        .contentType( MediaType.APPLICATION_JSON )
+                        .content( "{\"status\":\"confirmed\"}" ) )
+                .andExpect( status().isOk() );
+
+        final var updatedAssociation = associationsRepository.findById( "MKAssociation001" ).get();
+
+        Assertions.assertEquals( "confirmed", updatedAssociation.getStatus() );
+        Assertions.assertNotNull( updatedAssociation.getApprovedAt() );
+        Assertions.assertNotNull( updatedAssociation.getEtag() );
+        Assertions.assertEquals( 1, updatedAssociation.getPreviousStates().size() );
+        Assertions.assertEquals( "migrated", updatedAssociation.getPreviousStates().getFirst().getStatus() );
+        Assertions.assertEquals( "MKUser002", updatedAssociation.getPreviousStates().getFirst().getChangedBy() );
+        Assertions.assertNotNull(  updatedAssociation.getPreviousStates().getFirst().getChangedAt() );
+        Assertions.assertNull( updatedAssociation.getUserEmail() );
+        Assertions.assertEquals( "MKUser001", updatedAssociation.getUserId() );
+    }
+
+    @Test
+    void updateAssociationStatusForIdUpdatesAssociationWhenUserTriesToRemoveAnotherUsersMigratedAssociation() throws Exception {
+        associationsRepository.insert( testDataManager.fetchAssociationDaos( "MKAssociation001", "MKAssociation002" ) );
+        mockers.mockCompanyServiceFetchCompanyProfile( "MKCOMP001" );
+        mockers.mockUsersServiceSearchUserDetails( "MKUser001" );
+        mockers.mockUsersServiceFetchUserDetails( "MKUser002" );
+
+        mockMvc.perform( patch( "/associations/MKAssociation001"  )
+                        .header( "X-Request-Id", "theId123" )
+                        .header( "Eric-identity", "MKUser002" )
+                        .header( "ERIC-Identity-Type", "oauth2" )
+                        .contentType( MediaType.APPLICATION_JSON )
+                        .content( "{\"status\":\"removed\"}" ) )
+                .andExpect( status().isOk() );
+
+        final var updatedAssociation = associationsRepository.findById( "MKAssociation001" ).get();
+
+        Assertions.assertEquals( "removed", updatedAssociation.getStatus() );
+        Assertions.assertNotNull( updatedAssociation.getRemovedAt() );
+        Assertions.assertNotNull( updatedAssociation.getEtag() );
+        Assertions.assertEquals( 1, updatedAssociation.getPreviousStates().size() );
+        Assertions.assertEquals( "migrated", updatedAssociation.getPreviousStates().getFirst().getStatus() );
+        Assertions.assertEquals( "MKUser002", updatedAssociation.getPreviousStates().getFirst().getChangedBy() );
+        Assertions.assertNotNull(  updatedAssociation.getPreviousStates().getFirst().getChangedAt() );
+        Assertions.assertNull( updatedAssociation.getUserEmail() );
+        Assertions.assertEquals( "MKUser001", updatedAssociation.getUserId() );
+    }
+
+    @Test
+    void updateAssociationStatusForIdSendInvitationWhenUserTriesToConfirmNonexistentUsersMigratedAssociation() throws Exception {
+        associationsRepository.insert( testDataManager.fetchAssociationDaos( "MKAssociation001", "MKAssociation002" ) );
+        mockers.mockCompanyServiceFetchCompanyProfile( "MKCOMP001" );
+        mockers.mockUsersServiceSearchUserDetailsEmptyList( "mario@mushroom.kingdom" );
+        mockers.mockUsersServiceFetchUserDetails( "MKUser002" );
+
+        mockMvc.perform( patch( "/associations/MKAssociation001"  )
+                        .header( "X-Request-Id", "theId123" )
+                        .header( "Eric-identity", "MKUser002" )
+                        .header( "ERIC-Identity-Type", "oauth2" )
+                        .contentType( MediaType.APPLICATION_JSON )
+                        .content( "{\"status\":\"confirmed\"}" ) )
+                .andExpect( status().isOk() );
+
+        final var updatedAssociation = associationsRepository.findById( "MKAssociation001" ).get();
+
+        Assertions.assertEquals( "awaiting-approval", updatedAssociation.getStatus() );
+        Assertions.assertNotNull( updatedAssociation.getApprovalExpiryAt() );
+        Assertions.assertEquals( 1, updatedAssociation.getInvitations().size() );
+        Assertions.assertEquals( "MKUser002", updatedAssociation.getInvitations().getFirst().getInvitedBy() );
+        Assertions.assertNotNull( updatedAssociation.getInvitations().getFirst().getInvitedAt() );
+        Assertions.assertNotNull( updatedAssociation.getEtag() );
+        Assertions.assertEquals( 1, updatedAssociation.getPreviousStates().size() );
+        Assertions.assertEquals( "migrated", updatedAssociation.getPreviousStates().getFirst().getStatus() );
+        Assertions.assertEquals( "MKUser002", updatedAssociation.getPreviousStates().getFirst().getChangedBy() );
+        Assertions.assertNotNull(  updatedAssociation.getPreviousStates().getFirst().getChangedAt() );
+        Assertions.assertEquals( "mario@mushroom.kingdom", updatedAssociation.getUserEmail() );
+        Assertions.assertNull( updatedAssociation.getUserId() );
+    }
+
+    @Test
+    void updateAssociationStatusForIdUpdatesAssociationWhenUserTriesToRemoveNonexistentUsersMigratedAssociation() throws Exception {
+        associationsRepository.insert( testDataManager.fetchAssociationDaos( "MKAssociation001", "MKAssociation002" ) );
+        mockers.mockCompanyServiceFetchCompanyProfile( "MKCOMP001" );
+        mockers.mockUsersServiceSearchUserDetailsEmptyList( "mario@mushroom.kingdom" );
+        mockers.mockUsersServiceFetchUserDetails( "MKUser002" );
+
+        mockMvc.perform( patch( "/associations/MKAssociation001"  )
+                        .header( "X-Request-Id", "theId123" )
+                        .header( "Eric-identity", "MKUser002" )
+                        .header( "ERIC-Identity-Type", "oauth2" )
+                        .contentType( MediaType.APPLICATION_JSON )
+                        .content( "{\"status\":\"removed\"}" ) )
+                .andExpect( status().isOk() );
+
+        final var updatedAssociation = associationsRepository.findById( "MKAssociation001" ).get();
+
+        Assertions.assertEquals( "removed", updatedAssociation.getStatus() );
+        Assertions.assertNotNull( updatedAssociation.getRemovedAt() );
+        Assertions.assertNotNull( updatedAssociation.getEtag() );
+        Assertions.assertEquals( 1, updatedAssociation.getPreviousStates().size() );
+        Assertions.assertEquals( "migrated", updatedAssociation.getPreviousStates().getFirst().getStatus() );
+        Assertions.assertEquals( "MKUser002", updatedAssociation.getPreviousStates().getFirst().getChangedBy() );
+        Assertions.assertNotNull(  updatedAssociation.getPreviousStates().getFirst().getChangedAt() );
+        Assertions.assertEquals( "mario@mushroom.kingdom", updatedAssociation.getUserEmail() );
+        Assertions.assertNull( updatedAssociation.getUserId() );
+    }
+
     @AfterEach
     public void after() {
         mongoTemplate.dropCollection(AssociationDao.class);
diff --git a/src/test/java/uk/gov/companieshouse/accounts/association/utils/AssociationsUtilTest.java b/src/test/java/uk/gov/companieshouse/accounts/association/utils/AssociationsUtilTest.java
new file mode 100644
index 00000000..70edef52
--- /dev/null
+++ b/src/test/java/uk/gov/companieshouse/accounts/association/utils/AssociationsUtilTest.java
@@ -0,0 +1,90 @@
+package uk.gov.companieshouse.accounts.association.utils;
+
+import static uk.gov.companieshouse.accounts.association.utils.AssociationsUtil.mapToConfirmedUpdate;
+import static uk.gov.companieshouse.accounts.association.utils.AssociationsUtil.mapToInvitationUpdate;
+import static uk.gov.companieshouse.accounts.association.utils.AssociationsUtil.mapToRemovedUpdate;
+
+import org.bson.Document;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import uk.gov.companieshouse.accounts.association.common.TestDataManager;
+import uk.gov.companieshouse.accounts.association.models.InvitationDao;
+import uk.gov.companieshouse.accounts.association.models.PreviousStatesDao;
+
+@ExtendWith( MockitoExtension.class )
+@Tag( "unit-test" )
+class AssociationsUtilTest {
+
+    private static final TestDataManager testDataManager = TestDataManager.getInstance();
+
+    @Test
+    void mapToInvitationUpdateWithNullTargetAssociationThrowsNullPointerException(){
+        final var targetUser = testDataManager.fetchUserDtos( "MKUser001" ).getFirst();
+        Assertions.assertThrows( NullPointerException.class, () -> mapToInvitationUpdate( null, targetUser, "MKUser002" ) );
+    }
+
+    @Test
+    void mapToInvitationUpdateWithTargetUserSwapsUserIdAndUserEmail(){
+        final var targetAssociation = testDataManager.fetchAssociationDaos( "MKAssociation001" ).getFirst();
+        final var targetUser = testDataManager.fetchUserDtos( "MKUser001" ).getFirst();
+
+        final var update = mapToInvitationUpdate( targetAssociation, targetUser, "MKUser002" );
+        final var documentSet = update.getUpdateObject().get( "$set", Document.class );
+        final var documentPush = update.getUpdateObject().get( "$push", Document.class );
+        final var invitations = (InvitationDao) documentPush.get( "invitations" );
+        final var previousStates = (PreviousStatesDao) documentPush.get( "previous_states" );
+
+        Assertions.assertEquals( "awaiting-approval", documentSet.get( "status" ) );
+        Assertions.assertEquals( "MKUser002", invitations.getInvitedBy() );
+        Assertions.assertNotNull( invitations.getInvitedAt() );
+        Assertions.assertNotNull( documentSet.get( "approval_expiry_at" ) );
+        Assertions.assertNotNull( documentSet.get( "etag" ) );
+        Assertions.assertEquals( "migrated", previousStates.getStatus() );
+        Assertions.assertEquals( "MKUser002", previousStates.getChangedBy() );
+        Assertions.assertNotNull(  previousStates.getChangedAt() );
+        Assertions.assertNull( documentSet.get( "user_email" ) );
+        Assertions.assertEquals( "MKUser001", documentSet.get( "user_id" ) );
+    }
+
+    @Test
+    void mapToInvitationUpdateWithoutTargetUserSwapsUserIdAndUserEmail(){
+        final var targetAssociation = testDataManager.fetchAssociationDaos( "MKAssociation001" ).getFirst();
+
+        final var update = mapToInvitationUpdate( targetAssociation, null, "MKUser002" );
+        final var documentSet = update.getUpdateObject().get( "$set", Document.class );
+        final var documentPush = update.getUpdateObject().get( "$push", Document.class );
+        final var invitations = (InvitationDao) documentPush.get( "invitations" );
+        final var previousStates = (PreviousStatesDao) documentPush.get( "previous_states" );
+
+        Assertions.assertEquals( "awaiting-approval", documentSet.get( "status" ) );
+        Assertions.assertEquals( "MKUser002", invitations.getInvitedBy() );
+        Assertions.assertNotNull( invitations.getInvitedAt() );
+        Assertions.assertNotNull( documentSet.get( "approval_expiry_at" ) );
+        Assertions.assertNotNull( documentSet.get( "etag" ) );
+        Assertions.assertEquals( "migrated", previousStates.getStatus() );
+        Assertions.assertEquals( "MKUser002", previousStates.getChangedBy() );
+        Assertions.assertNotNull(  previousStates.getChangedAt() );
+    }
+
+    @Test
+    void mapToConfirmedUpdateCarriesOutUpdateCorrectly(){
+        final var targetAssociation = testDataManager.fetchAssociationDaos( "MKAssociation001" ).getFirst();
+        final var update = mapToConfirmedUpdate( targetAssociation, null, "MKUser002" );
+        final var documentSet = update.getUpdateObject().get( "$set", Document.class );
+        Assertions.assertEquals( "confirmed", documentSet.get( "status" ) );
+        Assertions.assertNotNull( documentSet.get( "approved_at" ) );
+    }
+
+    @Test
+    void mapToRemovedUpdateCarriesOutUpdateCorrectly(){
+        final var targetAssociation = testDataManager.fetchAssociationDaos( "MKAssociation001" ).getFirst();
+        final var update = mapToRemovedUpdate( targetAssociation, null, "MKUser002" );
+        final var documentSet = update.getUpdateObject().get( "$set", Document.class );
+        Assertions.assertEquals( "removed", documentSet.get( "status" ) );
+        Assertions.assertNotNull( documentSet.get( "removed_at" ) );
+    }
+
+}
diff --git a/src/test/java/uk/gov/companieshouse/accounts/association/utils/UserUtilTest.java b/src/test/java/uk/gov/companieshouse/accounts/association/utils/UserUtilTest.java
new file mode 100644
index 00000000..fe65d56b
--- /dev/null
+++ b/src/test/java/uk/gov/companieshouse/accounts/association/utils/UserUtilTest.java
@@ -0,0 +1,37 @@
+package uk.gov.companieshouse.accounts.association.utils;
+
+import static uk.gov.companieshouse.accounts.association.utils.UserUtil.mapToDisplayValue;
+
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.junit.jupiter.MockitoExtension;
+import uk.gov.companieshouse.accounts.association.common.TestDataManager;
+import uk.gov.companieshouse.api.accounts.user.model.User;
+
+@ExtendWith( MockitoExtension.class )
+@Tag( "unit-test" )
+class UserUtilTest {
+
+    private static final TestDataManager testDataManager = TestDataManager.getInstance();
+
+    private static Stream<Arguments> mapToDisplayValueScenarios(){
+        return Stream.of(
+                Arguments.of( null, null, null ),
+                Arguments.of( null, "Not provided", "Not provided" ),
+                Arguments.of( testDataManager.fetchUserDtos( "MKUser001" ).getFirst(), "Not provided", "Mario" ),
+                Arguments.of( testDataManager.fetchUserDtos( "222" ).getFirst(), "Not provided", "the.joker@gotham.city" ) );
+    }
+
+    @ParameterizedTest
+    @MethodSource( "mapToDisplayValueScenarios" )
+    void mapToDisplayValueCorrectlyDerivesDisplayValue( final User user, final String defaultValue, final String expectedOutcome ){
+        Assertions.assertEquals( expectedOutcome, mapToDisplayValue( user, defaultValue ) );
+    }
+
+}


Reviews:
user: login: kkonuganti-ch
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T15:45:53Z
user: login: krishna-patel-ch
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T15:51:26Z
user: login: kkonuganti-ch
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T09:24:20Z

Review comments:
diff_hunk: @@ -107,53 +111,47 @@ public ResponseEntity<PreviousStatesList> getPreviousStatesForAssociation(@Patte
         return null;
     }
 
-    private void throwBadRequestWhenUserIsNotPermittedToPerformAction( final String xRequestId, final String requestingUserId, final AssociationDao associationDao, final RequestBodyPut.StatusEnum newStatus, final boolean requestingAndTargetUserMatches ){
-        if ( !requestingAndTargetUserMatches && ( newStatus.equals( CONFIRMED ) || !associationsService.confirmedAssociationExists( associationDao.getCompanyNumber(), requestingUserId ) ) ) {
-            LOGGER.errorContext( xRequestId, new Exception( String.format( "Requesting %s user cannot change another user to confirmed or the requesting user is not associated with company %s", requestingUserId, associationDao.getCompanyNumber() ) ), null );
+    private void throwBadRequestWhenUserIsNotPermittedToPerformAction( final AssociationDao targetAssociation, final RequestBodyPut.StatusEnum newStatus, final boolean requestingAndTargetUserMatches ){
+        final var oldStatus = StatusEnum.fromValue( targetAssociation.getStatus() );
path: src/main/java/uk/gov/companieshouse/accounts/association/controller/UserCompanyAssociation.java
user: login: kkonuganti-ch
body: hi, requesting user should be able to change their status to confirmed, once they verify authcode.
so when user logs in the status will be migrated and next to it there will a button to verify company.
user will then verify the company and at that point once the user confirms the authcode, front end will send a patch request to confirm. We will also have a new notification at this point.
created_at: 2025-03-19T15:30:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -107,53 +111,47 @@ public ResponseEntity<PreviousStatesList> getPreviousStatesForAssociation(@Patte
         return null;
     }
 
-    private void throwBadRequestWhenUserIsNotPermittedToPerformAction( final String xRequestId, final String requestingUserId, final AssociationDao associationDao, final RequestBodyPut.StatusEnum newStatus, final boolean requestingAndTargetUserMatches ){
-        if ( !requestingAndTargetUserMatches && ( newStatus.equals( CONFIRMED ) || !associationsService.confirmedAssociationExists( associationDao.getCompanyNumber(), requestingUserId ) ) ) {
-            LOGGER.errorContext( xRequestId, new Exception( String.format( "Requesting %s user cannot change another user to confirmed or the requesting user is not associated with company %s", requestingUserId, associationDao.getCompanyNumber() ) ), null );
+    private void throwBadRequestWhenUserIsNotPermittedToPerformAction( final AssociationDao targetAssociation, final RequestBodyPut.StatusEnum newStatus, final boolean requestingAndTargetUserMatches ){
+        final var oldStatus = StatusEnum.fromValue( targetAssociation.getStatus() );
path: src/main/java/uk/gov/companieshouse/accounts/association/controller/UserCompanyAssociation.java
user: login: krishna-patel-ch
body: Hello. Actually, the user will call the `POST /associations` endpoint for the auth_code scenario. They can't use the PATCH endpoint to confirm their own migrated association. 

If one user tries to use the `PATCH` endpoint to confirm another user's migrated association, and the migrated user doesn't exist, then we send them an invitation email.
created_at: 2025-03-19T15:51:25Z
author_association: CONTRIBUTOR

PR comments:
user: login: ch-code-analysis
created_at: 2025-03-19T14:53:24Z
body: CI: No security warnings found
author_association: NONE
user: login: chsonarqubeprchecks[bot]
created_at: 2025-03-19T15:14:56Z
body: ## [![Quality Gate passed](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarqube.companieshouse.gov.uk/dashboard?id=uk.gov.companieshouse%3Aaccounts-association-api&pullRequest=173) **Quality Gate passed**  
Issues  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonarqube.companieshouse.gov.uk/project/issues?id=uk.gov.companieshouse%3Aaccounts-association-api&pullRequest=173&resolved=false) [0 New issues](https://sonarqube.companieshouse.gov.uk/project/issues?id=uk.gov.companieshouse%3Aaccounts-association-api&pullRequest=173&resolved=false)  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/accepted-16px.png '')](https://sonarqube.companieshouse.gov.uk/project/issues?id=uk.gov.companieshouse%3Aaccounts-association-api&pullRequest=173&issueStatuses=ACCEPTED) [0 Accepted issues](https://sonarqube.companieshouse.gov.uk/project/issues?id=uk.gov.companieshouse%3Aaccounts-association-api&pullRequest=173&issueStatuses=ACCEPTED)

Measures  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonarqube.companieshouse.gov.uk/security_hotspots?id=uk.gov.companieshouse%3Aaccounts-association-api&pullRequest=173) [0 Security Hotspots](https://sonarqube.companieshouse.gov.uk/security_hotspots?id=uk.gov.companieshouse%3Aaccounts-association-api&pullRequest=173)  
![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/no-data-16px.png '') No data about Coverage  
![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/no-data-16px.png '') No data about Duplication  
  
[See analysis details on SonarQube](https://sonarqube.companieshouse.gov.uk/dashboard?id=uk.gov.companieshouse%3Aaccounts-association-api&pullRequest=173)


author_association: NONE
user: login: ch-code-analysis
created_at: 2025-03-19T15:18:58Z
body: CI: No security warnings found
author_association: NONE