diff --git a/src/integrationTest/java/uk/gov/hmcts/reform/preapi/services/RecordingServiceIT.java b/src/integrationTest/java/uk/gov/hmcts/reform/preapi/services/RecordingServiceIT.java index 5ef9841ac..dfbc82614 100644 --- a/src/integrationTest/java/uk/gov/hmcts/reform/preapi/services/RecordingServiceIT.java +++ b/src/integrationTest/java/uk/gov/hmcts/reform/preapi/services/RecordingServiceIT.java @@ -19,7 +19,6 @@ import uk.gov.hmcts.reform.preapi.entities.Court; import uk.gov.hmcts.reform.preapi.entities.Recording; import uk.gov.hmcts.reform.preapi.enums.CaseState; -import uk.gov.hmcts.reform.preapi.enums.CaseState; import uk.gov.hmcts.reform.preapi.enums.CourtType; import uk.gov.hmcts.reform.preapi.enums.RecordingOrigin; import uk.gov.hmcts.reform.preapi.enums.UpsertResult; @@ -32,7 +31,10 @@ import java.time.Duration; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -567,6 +569,139 @@ void searchRecordingsEnableMigratedDataToggleSuperUser() { assertThat(results2.getTotalElements()).isEqualTo(3); } + @Test + @Transactional + @DisplayName("Mark VF original recordings as deleted/not deleted") + void markVfOriginalRecordingsAsDeleted() { + mockAdminUser(); + + // Set up + Recording preRecording = createSampleOriginalRecording(RecordingOrigin.PRE); + + Recording vfOriginal1 = createSampleOriginalRecording(RecordingOrigin.VODAFONE); + + Recording vfOriginal2 = createSampleOriginalRecording(RecordingOrigin.VODAFONE); + + Recording reencodedVf1 = createSampleReencodedRecording(vfOriginal1, 2); + + // Pre-test check + Map recordingsBeforeFlagSet = recordingService.findAll( + new SearchRecordings(), + false, Pageable.unpaged() + ) + .stream() + .collect(Collectors.toMap(RecordingDTO::getId, Function.identity())); + + checkResultsForDeletedRecordings( + recordingsBeforeFlagSet, + List.of(), + List.of(preRecording.getId(), vfOriginal1.getId(), reencodedVf1.getId(), vfOriginal2.getId()) + ); + + // Test: delete originals + recordingService.deleteOriginalWhereReencodedVersionExists(); + entityManager.flush(); + + Map recordingsAfterDeletion = recordingService.findAll( + new SearchRecordings(), + false, + Pageable.unpaged() + ) + .stream() + .collect(Collectors.toMap(RecordingDTO::getId, Function.identity())); + + checkResultsForDeletedRecordings( + recordingsAfterDeletion, + List.of(vfOriginal1.getId()), + List.of(preRecording.getId(), reencodedVf1.getId(), vfOriginal2.getId()) + ); + + recordingService.undeleteOriginalWhereReencodedVersionExists(); + entityManager.flush(); + + Map recordingsAfterUndeletion = recordingService.findAll( + new SearchRecordings(), + false, + Pageable.unpaged() + ) + .stream() + .collect(Collectors.toMap(RecordingDTO::getId, Function.identity())); + + checkResultsForDeletedRecordings( + recordingsAfterUndeletion, + List.of(), + List.of( + preRecording.getId(), reencodedVf1.getId(), vfOriginal2.getId(), + vfOriginal1.getId() + ) + ); + + } + + + @Test + @Transactional + @DisplayName("Non-admin user cannot mark VF original recordings as deleted/not deleted") + void nonAdminUserCannotMarkVfOriginalRecordingsAsDeleted() { + mockNonAdminUser(); + + String message = Assertions.assertThrows( + AccessDeniedException.class, + () -> recordingService.deleteOriginalWhereReencodedVersionExists() + ).getMessage(); + + assertThat(message) + .isEqualTo("User does not have sufficient privileges to delete original recordings"); + + String message2 = Assertions.assertThrows( + AccessDeniedException.class, + () -> recordingService.undeleteOriginalWhereReencodedVersionExists() + ).getMessage(); + + assertThat(message2) + .isEqualTo("User does not have sufficient privileges to undelete original recordings"); + } + + private Recording createSampleOriginalRecording(RecordingOrigin recordingOrigin) { + CaptureSession sampleCaptureSession = persistSampleCourtCaseBookingCaptureSession( + recordingOrigin + ); + + Recording recording = HelperFactory.createRecording( + sampleCaptureSession, null, 1, + "", null + ); + recording.setDuration(Duration.ofHours(1)); + entityManager.persist(recording); + return recording; + } + + private Recording createSampleReencodedRecording(Recording parentRecording, + Integer versionNumber) { + Recording recording = HelperFactory.createRecording( + parentRecording.getCaptureSession(), parentRecording, versionNumber, + "", null + ); + recording.setReencode(true); + recording.setDuration(Duration.ofHours(1)); + entityManager.persist(recording); + return recording; + } + + private static void checkResultsForDeletedRecordings(Map results, + List shouldBeDeleted, + List shouldNotBeDeleted) { + + assertThat(results.size()).isEqualTo(shouldNotBeDeleted.size()); + + assertThat(results.keySet().containsAll(shouldNotBeDeleted)).isTrue(); + + for (UUID id : shouldBeDeleted) { + assertThat(results.containsKey(id)).isFalse(); + } + } + + private static void mockNonAdminUser(UUID courtId) { var mockAuth = mock(UserAuthentication.class); when(mockAuth.isAdmin()).thenReturn(false); @@ -574,4 +709,35 @@ private static void mockNonAdminUser(UUID courtId) { when(mockAuth.getCourtId()).thenReturn(courtId); SecurityContextHolder.getContext().setAuthentication(mockAuth); } + + private CaptureSession persistSampleCourtCaseBookingCaptureSession(RecordingOrigin recordingOrigin) { + Court court = HelperFactory.createCourt(CourtType.CROWN, "Example Court", "1234"); + entityManager.persist(court); + + Case sampleCase = HelperFactory.createCase(court, "CASE12345", true, null); + sampleCase.setOrigin(recordingOrigin); + entityManager.persist(sampleCase); + + Booking sampleBooking = HelperFactory.createBooking( + sampleCase, Timestamp.from(Instant.now()), + null, null + ); + entityManager.persist(sampleBooking); + + CaptureSession captureSession = HelperFactory.createCaptureSession( + sampleBooking, + recordingOrigin, + null, + null, + null, + null, + null, + null, + null, + null + ); + entityManager.persist(captureSession); + + return captureSession; + } } diff --git a/src/main/java/uk/gov/hmcts/reform/preapi/entities/Recording.java b/src/main/java/uk/gov/hmcts/reform/preapi/entities/Recording.java index c00e35968..159e2763c 100644 --- a/src/main/java/uk/gov/hmcts/reform/preapi/entities/Recording.java +++ b/src/main/java/uk/gov/hmcts/reform/preapi/entities/Recording.java @@ -67,6 +67,9 @@ public class Recording extends BaseEntity implements ISoftDeletable { @Column(name = "is_reencode", nullable = false) private boolean reencode; + @Column(name = "vf_original_reencoded", nullable = false) + private boolean vfOriginalNowReencoded; + @OneToMany(mappedBy = "sourceRecording") private Set editRequests; diff --git a/src/main/java/uk/gov/hmcts/reform/preapi/repositories/RecordingRepository.java b/src/main/java/uk/gov/hmcts/reform/preapi/repositories/RecordingRepository.java index e4b05c4f4..25e0288f7 100644 --- a/src/main/java/uk/gov/hmcts/reform/preapi/repositories/RecordingRepository.java +++ b/src/main/java/uk/gov/hmcts/reform/preapi/repositories/RecordingRepository.java @@ -208,4 +208,20 @@ List countRecordingsPerCase( """ ) List findAllOriginVodafoneNoDuration(); + + @Query(""" + SELECT r FROM Recording r + WHERE r.captureSession.origin = 'VODAFONE' + AND r.deletedAt IS NULL + AND r.id in + (SELECT reencoded.parentRecording.id FROM Recording reencoded + where reencoded.captureSession.origin = 'VODAFONE' + AND reencoded.deletedAt IS NULL + AND reencoded.reencode = true + ) + """ + ) + List findVodafoneOriginalRecordingsWhereReencodedVersionExists(); + + List findRecordingsByDeletedAtIsNotNullAndVfOriginalNowReencodedIsTrue(); } diff --git a/src/main/java/uk/gov/hmcts/reform/preapi/services/RecordingService.java b/src/main/java/uk/gov/hmcts/reform/preapi/services/RecordingService.java index c40cc6316..c8ce39f3f 100644 --- a/src/main/java/uk/gov/hmcts/reform/preapi/services/RecordingService.java +++ b/src/main/java/uk/gov/hmcts/reform/preapi/services/RecordingService.java @@ -9,6 +9,7 @@ import org.springframework.context.annotation.Lazy; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; @@ -231,6 +232,42 @@ public void deleteById(UUID recordingId) { recordingRepository.saveAndFlush(recording); } + @Transactional + public void deleteOriginalWhereReencodedVersionExists() { + UserAuthentication auth = (UserAuthentication) SecurityContextHolder.getContext().getAuthentication(); + if (!auth.isAdmin()) { + throw new AccessDeniedException("User does not have sufficient privileges to delete original recordings"); + } + + recordingRepository.findVodafoneOriginalRecordingsWhereReencodedVersionExists() + .forEach(recording -> { + recording.setVfOriginalNowReencoded(Boolean.TRUE); + recording.setDeletedAt(Timestamp.from(Instant.now())); + recording.setDeleted(Boolean.TRUE); + recordingRepository.save(recording); + }); + + recordingRepository.flush(); + } + + @Transactional + public void undeleteOriginalWhereReencodedVersionExists() { + UserAuthentication auth = (UserAuthentication) SecurityContextHolder.getContext().getAuthentication(); + if (!auth.isAdmin()) { + throw new AccessDeniedException("User does not have sufficient privileges to undelete original recordings"); + } + + recordingRepository.findRecordingsByDeletedAtIsNotNullAndVfOriginalNowReencodedIsTrue() + .forEach(recording -> { + // Leave originalVfRecording flag set - doesn't need to be reset + recording.setDeletedAt(null); + recording.setDeleted(Boolean.FALSE); + recordingRepository.save(recording); + }); + + recordingRepository.flush(); + } + @Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class) public void checkIfCaptureSessionHasAssociatedRecordings(CaptureSession captureSession) { Optional recording = recordingRepository.findFirstByCaptureSessionAndDeletedAtIsNull(captureSession); @@ -303,7 +340,6 @@ public List findAllDurationNull() { .toList(); } - @Transactional public List findAllVodafoneRecordings() { // return recordingRepository.findAllOriginVodafone().stream() @@ -311,5 +347,4 @@ public List findAllVodafoneRecordings() { .map(RecordingDTO::new) .collect(Collectors.toList()); } - } diff --git a/src/main/java/uk/gov/hmcts/reform/preapi/tasks/UpdateRecordingsVisibility.java b/src/main/java/uk/gov/hmcts/reform/preapi/tasks/UpdateRecordingsVisibility.java new file mode 100644 index 000000000..12f38b313 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/preapi/tasks/UpdateRecordingsVisibility.java @@ -0,0 +1,49 @@ +package uk.gov.hmcts.reform.preapi.tasks; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import uk.gov.hmcts.reform.preapi.security.service.UserAuthenticationService; +import uk.gov.hmcts.reform.preapi.services.RecordingService; +import uk.gov.hmcts.reform.preapi.services.UserService; + +/** + * Decides whether to make *VF original recordings* visible, depending on HIDE_REENCODED_RECORDINGS flag + * Needs to be run after flag has been updated to hide/display VF recordings + * Re-encoded recordings' visibility is controlled directly by the flag in application code + * This approach uses the existing logic around deleted recordings to control visibility instead. + * It is intended as a temporary measure while we monitor re-encoded recordings for any issues. + * If all is well, VF originals can remain deleted, and we can delete this task. + */ +@Slf4j +@Component +public class UpdateRecordingsVisibility extends RobotUserTask { + + private final boolean hideReencodedRecordings; + private final RecordingService recordingService; + + @Autowired + public UpdateRecordingsVisibility(RecordingService recordingService, + UserService userService, + UserAuthenticationService userAuthenticationService, + @Value("${cron-user-email}") String cronUserEmail, + @Value("${HIDE_REENCODED_RECORDINGS:}") String hideReencodedRecordings) { + super(userService, userAuthenticationService, cronUserEmail); + this.recordingService = recordingService; + this.hideReencodedRecordings = Boolean.parseBoolean(hideReencodedRecordings); + } + + @Override + public void run() { + signInRobotUser(); + + if (hideReencodedRecordings) { + log.info("Re-encoded recordings are hidden. Undeleting originals..."); + recordingService.undeleteOriginalWhereReencodedVersionExists(); + } else { + recordingService.deleteOriginalWhereReencodedVersionExists(); + } + } + +} diff --git a/src/main/resources/db/migration/V055__AddVfOriginalReencodedFlagToRecordings.sql b/src/main/resources/db/migration/V055__AddVfOriginalReencodedFlagToRecordings.sql new file mode 100644 index 000000000..b38defec6 --- /dev/null +++ b/src/main/resources/db/migration/V055__AddVfOriginalReencodedFlagToRecordings.sql @@ -0,0 +1,2 @@ +ALTER TABLE recordings + ADD COLUMN vf_original_reencoded BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/test/java/uk/gov/hmcts/reform/preapi/services/RecordingServiceTest.java b/src/test/java/uk/gov/hmcts/reform/preapi/services/RecordingServiceTest.java index ff45c5061..13bbca311 100644 --- a/src/test/java/uk/gov/hmcts/reform/preapi/services/RecordingServiceTest.java +++ b/src/test/java/uk/gov/hmcts/reform/preapi/services/RecordingServiceTest.java @@ -1,13 +1,16 @@ package uk.gov.hmcts.reform.preapi.services; +import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.context.bean.override.mockito.MockitoBean; import uk.gov.hmcts.reform.preapi.controllers.params.SearchRecordings; @@ -52,6 +55,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @SpringBootTest(classes = RecordingService.class) @@ -829,4 +833,85 @@ void findAllVodafoneRecordingsSuccess() { assertThat(results.getFirst().getId()).isEqualTo(recordingEntity.getId()); verify(recordingRepository, times(1)).findAllOriginVodafoneNoDuration(); } + + @Test + @DisplayName("Undelete VF originals") + void undeleteVFOriginals() { + var mockAuth = mock(UserAuthentication.class); + when(mockAuth.isAdmin()).thenReturn(true); + SecurityContextHolder.getContext().setAuthentication(mockAuth); + + Recording vfOriginalWithReencodedVersion = new Recording(); + vfOriginalWithReencodedVersion.setId(UUID.randomUUID()); + vfOriginalWithReencodedVersion.setDeleted(Boolean.TRUE); + vfOriginalWithReencodedVersion.setDeletedAt(Timestamp.from(Instant.now())); + vfOriginalWithReencodedVersion.setVfOriginalNowReencoded(Boolean.TRUE); + + when(recordingRepository.findRecordingsByDeletedAtIsNotNullAndVfOriginalNowReencodedIsTrue()) + .thenReturn(List.of(vfOriginalWithReencodedVersion)); + + recordingService.undeleteOriginalWhereReencodedVersionExists(); + + verify(recordingRepository, times(1)) + .findRecordingsByDeletedAtIsNotNullAndVfOriginalNowReencodedIsTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Recording.class); + verify(recordingRepository, times(1)).save(captor.capture()); + + Recording savedRecording = captor.getValue(); + assertThat(savedRecording.getId()).isEqualTo(vfOriginalWithReencodedVersion.getId()); + assertThat(savedRecording.isDeleted()).isFalse(); + assertThat(savedRecording.isVfOriginalNowReencoded()).isTrue(); + + verify(recordingRepository, times(1)).flush(); + + verifyNoMoreInteractions(recordingRepository); + } + + @Test + @DisplayName("Delete original VF recordings where re-encoded version exists") + void deleteVFWhereReEncodedVersionExists() { + var mockAuth = mock(UserAuthentication.class); + when(mockAuth.isAdmin()).thenReturn(true); + SecurityContextHolder.getContext().setAuthentication(mockAuth); + + Recording vfOriginalWithReencodedVersion = new Recording(); + vfOriginalWithReencodedVersion.setId(UUID.randomUUID()); + + when(recordingRepository.findVodafoneOriginalRecordingsWhereReencodedVersionExists()) + .thenReturn(List.of(vfOriginalWithReencodedVersion)); + + recordingService.deleteOriginalWhereReencodedVersionExists(); + + verify(recordingRepository, times(1)) + .findVodafoneOriginalRecordingsWhereReencodedVersionExists(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Recording.class); + verify(recordingRepository, times(1)).save(captor.capture()); + Recording capturedRecording = captor.getValue(); + Assertions.assertThat(capturedRecording.isDeleted()).isTrue(); + Assertions.assertThat(capturedRecording.isVfOriginalNowReencoded()).isTrue(); + + verify(recordingRepository, times(1)).flush(); + + verifyNoMoreInteractions(recordingRepository); + } + + @Test + @DisplayName("Need to be admin user to delete/undelete VF originals") + void needToBeAdminUserToDelete() { + var mockAuth = mock(UserAuthentication.class); + when(mockAuth.isAdmin()).thenReturn(false); + SecurityContextHolder.getContext().setAuthentication(mockAuth); + + assertThrows( + AccessDeniedException.class, + () -> recordingService.undeleteOriginalWhereReencodedVersionExists() + ); + + assertThrows( + AccessDeniedException.class, + () -> recordingService.deleteOriginalWhereReencodedVersionExists() + ); + } } diff --git a/src/test/java/uk/gov/hmcts/reform/preapi/tasks/UpdateRecordingsVisibilityTest.java b/src/test/java/uk/gov/hmcts/reform/preapi/tasks/UpdateRecordingsVisibilityTest.java new file mode 100644 index 000000000..1dc54e4ce --- /dev/null +++ b/src/test/java/uk/gov/hmcts/reform/preapi/tasks/UpdateRecordingsVisibilityTest.java @@ -0,0 +1,93 @@ +package uk.gov.hmcts.reform.preapi.tasks; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import uk.gov.hmcts.reform.preapi.dto.AccessDTO; +import uk.gov.hmcts.reform.preapi.dto.base.BaseAppAccessDTO; +import uk.gov.hmcts.reform.preapi.entities.User; +import uk.gov.hmcts.reform.preapi.security.authentication.UserAuthentication; +import uk.gov.hmcts.reform.preapi.security.service.UserAuthenticationService; +import uk.gov.hmcts.reform.preapi.services.RecordingService; +import uk.gov.hmcts.reform.preapi.services.UserService; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@SpringBootTest(classes = UpdateRecordingsVisibility.class) +class UpdateRecordingsVisibilityTest { + + private static final String CRON_USER_EMAIL = "cron@example.com"; + + @MockitoBean + private RecordingService recordingService; + + @MockitoBean + private UserService userService; + + @MockitoBean + private User user; + + @MockitoBean + private UserAuthenticationService userAuthenticationService; + + @BeforeEach + void setUp() { + BaseAppAccessDTO appAccess = new BaseAppAccessDTO(); + appAccess.setId(UUID.randomUUID()); + + AccessDTO access = new AccessDTO(); + access.setAppAccess(Set.of(appAccess)); + + when(userService.findByEmail(CRON_USER_EMAIL)).thenReturn(access); + when(userAuthenticationService.validateUser(any())).thenReturn(Optional.of(mock(UserAuthentication.class))); + } + + @Test + @DisplayName("Undelete originals if Hide Reencodes flag is set to true") + void undeleteOriginalsIfHideReencodesFlagIsSetToTrue() { + UpdateRecordingsVisibility underTest = new UpdateRecordingsVisibility(recordingService, + userService, + userAuthenticationService, + CRON_USER_EMAIL, + "true"); + underTest.run(); + + verify(userService, times(1)).findByEmail(CRON_USER_EMAIL); + verify(userAuthenticationService, times(1)).validateUser(any()); + + verify(recordingService, times(1)).undeleteOriginalWhereReencodedVersionExists(); + + verifyNoMoreInteractions(recordingService); + } + + @Test + @DisplayName("Delete original VF recordings where re-encoded version exists") + void deleteVFWhereReEncodedVersionExists() { + UpdateRecordingsVisibility underTest = new UpdateRecordingsVisibility(recordingService, + userService, + userAuthenticationService, + CRON_USER_EMAIL, + "false"); + + underTest.run(); + + verify(userService, times(1)).findByEmail(CRON_USER_EMAIL); + verify(userAuthenticationService, times(1)).validateUser(any()); + + verify(recordingService, times(1)).deleteOriginalWhereReencodedVersionExists(); + + verifyNoMoreInteractions(recordingService); + } + +}