From 7453f05b1b5aea09a2d37fe2f174b85cd7b8c9e6 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:24:59 +0100 Subject: [PATCH 1/3] PDJB-302: Allow for JL invites to be hidden move to having a status for JL invites rather than a series of flags --- .../enums/JointLandlordInvitationStatus.kt | 7 +++++++ .../database/entity/JointLandlordInvitation.kt | 14 +++++++++++++- ...dd_is_hidden_to_joint_landlord_invitation.sql | 1 + .../entity/JointLandlordInvitationTests.kt | 16 ++++++++-------- 4 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/uk/gov/communities/prsdb/webapp/constants/enums/JointLandlordInvitationStatus.kt create mode 100644 src/main/resources/db/migrations/V1_29_0__add_is_hidden_to_joint_landlord_invitation.sql diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/constants/enums/JointLandlordInvitationStatus.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/constants/enums/JointLandlordInvitationStatus.kt new file mode 100644 index 0000000000..54c42fc8ca --- /dev/null +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/constants/enums/JointLandlordInvitationStatus.kt @@ -0,0 +1,7 @@ +package uk.gov.communities.prsdb.webapp.constants.enums + +enum class JointLandlordInvitationStatus { + PENDING, + EXPIRED, + HIDDEN, +} diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/database/entity/JointLandlordInvitation.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/database/entity/JointLandlordInvitation.kt index ac99c53f82..6b4a607221 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/database/entity/JointLandlordInvitation.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/database/entity/JointLandlordInvitation.kt @@ -13,6 +13,7 @@ import kotlinx.datetime.plus import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toKotlinInstant import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS +import uk.gov.communities.prsdb.webapp.constants.enums.JointLandlordInvitationStatus import uk.gov.communities.prsdb.webapp.helpers.DateTimeHelper import java.time.temporal.ChronoUnit import java.util.UUID @@ -41,13 +42,16 @@ class JointLandlordInvitation( lateinit var invitingLandlord: Landlord private set + @Column(nullable = false) + var isHidden: Boolean = false + val expiresOnDate: LocalDate get() = DateTimeHelper .getDateInUK(createdDate.toKotlinInstant()) .plus(DatePeriod(days = JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS)) - val isExpired: Boolean + private val isExpired: Boolean get() = DateTimeHelper().getCurrentDateInUK() > expiresOnDate val daysUntilExpiry: Long @@ -58,6 +62,14 @@ class JointLandlordInvitation( expiresOnDate.toJavaLocalDate(), ).coerceAtLeast(0) + val status: JointLandlordInvitationStatus + get() = + when { + isHidden -> JointLandlordInvitationStatus.HIDDEN + isExpired -> JointLandlordInvitationStatus.EXPIRED + else -> JointLandlordInvitationStatus.PENDING + } + constructor( token: UUID, email: String, diff --git a/src/main/resources/db/migrations/V1_29_0__add_is_hidden_to_joint_landlord_invitation.sql b/src/main/resources/db/migrations/V1_29_0__add_is_hidden_to_joint_landlord_invitation.sql new file mode 100644 index 0000000000..0b47907f4a --- /dev/null +++ b/src/main/resources/db/migrations/V1_29_0__add_is_hidden_to_joint_landlord_invitation.sql @@ -0,0 +1 @@ +ALTER TABLE joint_landlord_invitation ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/database/entity/JointLandlordInvitationTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/database/entity/JointLandlordInvitationTests.kt index bc15372639..6c055703b5 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/database/entity/JointLandlordInvitationTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/database/entity/JointLandlordInvitationTests.kt @@ -1,35 +1,35 @@ package uk.gov.communities.prsdb.webapp.database.entity -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS +import uk.gov.communities.prsdb.webapp.constants.enums.JointLandlordInvitationStatus import uk.gov.communities.prsdb.webapp.testHelpers.mockObjects.MockJointLandlordData import java.time.Instant import java.time.temporal.ChronoUnit +import kotlin.test.assertEquals class JointLandlordInvitationTests { @Test - fun `isExpired returns false when the current day is earlier than the expiry date`() { + fun `status returns PENDING when the current day is earlier than the expiry date`() { val createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS - 1).toLong(), ChronoUnit.DAYS) val invitation = MockJointLandlordData.createJointLandlordInvitation(createdDate = createdDate) - assertFalse(invitation.isExpired) + assertEquals(invitation.status, JointLandlordInvitationStatus.PENDING) } @Test - fun `isExpired returns false when the current day equals the expiry date`() { + fun `status returns PENDING when the current day equals the expiry date`() { val createdDate = Instant.now().minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS) val invitation = MockJointLandlordData.createJointLandlordInvitation(createdDate = createdDate) - assertFalse(invitation.isExpired) + assertEquals(invitation.status, JointLandlordInvitationStatus.PENDING) } @Test - fun `isExpired returns true when the current day is later than the expiry date`() { + fun `status returns EXPIRED when the current day is later than the expiry date`() { val createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 1).toLong(), ChronoUnit.DAYS) val invitation = MockJointLandlordData.createJointLandlordInvitation(createdDate = createdDate) - assertTrue(invitation.isExpired) + assertEquals(invitation.status, JointLandlordInvitationStatus.EXPIRED) } } From ea682bd56030ed78bfe3fc39f5865290d5ec0227 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:29:16 +0100 Subject: [PATCH 2/3] PDJB-302: Add invite hiding logic to frontend --- .../webapp/constants/UrlSegmentConstants.kt | 1 + .../controllers/PropertyDetailsController.kt | 22 ++++++++++++ .../models/viewModels/InvitationViewModel.kt | 9 +++++ .../JointLandlordInvitationService.kt | 34 +++++++++++++++++-- .../resources/messages/propertyDetails.yml | 3 ++ .../templates/propertyDetailsView.html | 13 +++++-- 6 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/constants/UrlSegmentConstants.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/constants/UrlSegmentConstants.kt index 368f311ce3..f2b97fd915 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/constants/UrlSegmentConstants.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/constants/UrlSegmentConstants.kt @@ -51,3 +51,4 @@ const val SECURITY_PATH_SEGMENT = "security.txt" const val JOINT_LANDLORD_INVITATION_PATH_SEGMENT = "joint-landlord-invitation" const val JOIN_PROPERTY_PATH_SEGMENT = "join-property" const val SUBJECT_IDENTIFIER_PATH_SEGMENT = "subject-identifier" +const val REMOVE_EXPIRED_INVITE_PATH_SEGMENT = "remove-expired-invite" diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/controllers/PropertyDetailsController.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/controllers/PropertyDetailsController.kt index 37f288cd16..8dbdd2f591 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/controllers/PropertyDetailsController.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/controllers/PropertyDetailsController.kt @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.servlet.ModelAndView +import org.springframework.web.servlet.mvc.support.RedirectAttributes import org.springframework.web.util.UriTemplate import uk.gov.communities.prsdb.webapp.annotations.webAnnotations.PrsdbController import uk.gov.communities.prsdb.webapp.config.interceptors.BackLinkInterceptor.Companion.overrideBackLinkForUrl @@ -19,6 +20,7 @@ import uk.gov.communities.prsdb.webapp.constants.LANDLORD_DETAILS_FRAGMENT import uk.gov.communities.prsdb.webapp.constants.LANDLORD_PATH_SEGMENT import uk.gov.communities.prsdb.webapp.constants.LOCAL_COUNCIL_PATH_SEGMENT import uk.gov.communities.prsdb.webapp.constants.PROPERTY_DETAILS_SEGMENT +import uk.gov.communities.prsdb.webapp.constants.REMOVE_EXPIRED_INVITE_PATH_SEGMENT import uk.gov.communities.prsdb.webapp.controllers.LandlordController.Companion.LANDLORD_DASHBOARD_URL import uk.gov.communities.prsdb.webapp.controllers.LocalCouncilDashboardController.Companion.LOCAL_COUNCIL_DASHBOARD_URL import uk.gov.communities.prsdb.webapp.helpers.DateTimeHelper @@ -108,6 +110,19 @@ class PropertyDetailsController( return modelAndView } + @PreAuthorize("hasRole('LANDLORD')") + @GetMapping(REMOVE_EXPIRED_INVITE_ROUTE) + fun removeExpiredInvite( + @PathVariable propertyOwnershipId: Long, + @PathVariable invitationId: Long, + redirectAttributes: RedirectAttributes, + ): String { + val baseUserId = SecurityContextHolder.getContext().authentication.name + jointLandlordInvitationService.hideExpiredInvitation(invitationId, baseUserId) + redirectAttributes.addFlashAttribute("inviteRemoved", true) + return "redirect:${getPropertyDetailsPath(propertyOwnershipId)}#$LANDLORD_DETAILS_FRAGMENT" + } + @PreAuthorize("hasAnyRole('LOCAL_COUNCIL_USER', 'LOCAL_COUNCIL_ADMIN')") @GetMapping(LOCAL_COUNCIL_PROPERTY_DETAILS_ROUTE) fun getPropertyDetailsLocalCouncilView( @@ -166,6 +181,8 @@ class PropertyDetailsController( companion object { const val LANDLORD_PROPERTY_DETAILS_ROUTE = "/$LANDLORD_PATH_SEGMENT/$PROPERTY_DETAILS_SEGMENT/{propertyOwnershipId}" + const val REMOVE_EXPIRED_INVITE_ROUTE = "$LANDLORD_PROPERTY_DETAILS_ROUTE/$REMOVE_EXPIRED_INVITE_PATH_SEGMENT/{invitationId}" + const val LOCAL_COUNCIL_PROPERTY_DETAILS_ROUTE = "/$LOCAL_COUNCIL_PATH_SEGMENT/$PROPERTY_DETAILS_SEGMENT/{propertyOwnershipId}" fun getPropertyDetailsPath( @@ -178,5 +195,10 @@ class PropertyDetailsController( fun getPropertyCompliancePath(propertyOwnershipId: Long): String = UriTemplate("$LANDLORD_PROPERTY_DETAILS_ROUTE#$COMPLIANCE_INFO_FRAGMENT").expand(propertyOwnershipId).toASCIIString() + + fun getRemoveExpiredInvitePath( + propertyOwnershipId: Long, + invitationId: Long, + ): String = UriTemplate(REMOVE_EXPIRED_INVITE_ROUTE).expand(propertyOwnershipId, invitationId).toASCIIString() } } diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/InvitationViewModel.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/InvitationViewModel.kt index 1a68e07bc3..d9f9414c5e 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/InvitationViewModel.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/InvitationViewModel.kt @@ -3,6 +3,7 @@ package uk.gov.communities.prsdb.webapp.models.viewModels import kotlinx.datetime.LocalDate import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toKotlinInstant +import uk.gov.communities.prsdb.webapp.controllers.PropertyDetailsController import uk.gov.communities.prsdb.webapp.database.entity.JointLandlordInvitation import uk.gov.communities.prsdb.webapp.helpers.DateTimeHelper import java.time.format.DateTimeFormatter @@ -16,8 +17,10 @@ data class PendingInvitationViewModel( ) data class ExpiredInvitationViewModel( + val invitationId: Long, val email: String, val expiredDate: String, + val removeFromListUrl: String, ) class InvitationViewModelBuilder { @@ -34,8 +37,14 @@ class InvitationViewModelBuilder { fun buildExpiredViewModel(invitation: JointLandlordInvitation): ExpiredInvitationViewModel = ExpiredInvitationViewModel( + invitationId = invitation.id, email = invitation.invitedEmail, expiredDate = formatDate(invitation.expiresOnDate), + removeFromListUrl = + PropertyDetailsController.getRemoveExpiredInvitePath( + invitation.registeredOwnership.id, + invitation.id, + ), ) private fun formatDate(date: LocalDate): String = date.toJavaLocalDate().format(DATE_FORMATTER) diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationService.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationService.kt index ed7879409f..b9e346dadf 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationService.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationService.kt @@ -1,9 +1,13 @@ package uk.gov.communities.prsdb.webapp.services import jakarta.servlet.http.HttpSession +import jakarta.transaction.Transactional +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException import uk.gov.communities.prsdb.webapp.annotations.webAnnotations.PrsdbWebService import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_TOKEN_WITH_ACCEPTANCE_JOURNEY_IDS import uk.gov.communities.prsdb.webapp.constants.USER_SENT_TO_LANDLORD_REGISTRATION_WHILE_ACCEPTING_JOINT_LANDLORD_INVITATION +import uk.gov.communities.prsdb.webapp.constants.enums.JointLandlordInvitationStatus import uk.gov.communities.prsdb.webapp.database.entity.JointLandlordInvitation import uk.gov.communities.prsdb.webapp.database.entity.Landlord import uk.gov.communities.prsdb.webapp.database.entity.PropertyOwnership @@ -21,12 +25,14 @@ class JointLandlordInvitationService( fun getPendingAndExpiredInvitations( propertyOwnership: PropertyOwnership, ): Pair, List> { - val (expired, pending) = + val grouped = invitationRepository .findByRegisteredOwnership(propertyOwnership) .sortedByDescending { it.createdDate } - .partition { it.isExpired } - return Pair(pending, expired) // flips the above pair from expired, pending to pending, expired + .groupBy { it.status } + val pending = grouped[JointLandlordInvitationStatus.PENDING].orEmpty() + val expired = grouped[JointLandlordInvitationStatus.EXPIRED].orEmpty() + return Pair(pending, expired) } fun sendInvitationEmails( @@ -111,4 +117,26 @@ class JointLandlordInvitationService( @Suppress("UNCHECKED_CAST") private fun getListOfPairsFromSession(sessionAttributeName: String): MutableList>? = session.getAttribute(sessionAttributeName) as? MutableList> + + @Transactional + fun hideExpiredInvitation( + invitationId: Long, + baseUserId: String, + ) { + val invitation = + invitationRepository.findById(invitationId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Invitation with id $invitationId was not found") + } + + if (invitation.registeredOwnership.primaryLandlord.baseUser.id != baseUserId) { + throw ResponseStatusException(HttpStatus.FORBIDDEN, "User is not authorized to modify this invitation") + } + + if (invitation.status != JointLandlordInvitationStatus.EXPIRED) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Only expired invitations can be hidden") + } + + invitation.isHidden = true + invitationRepository.save(invitation) + } } diff --git a/src/main/resources/messages/propertyDetails.yml b/src/main/resources/messages/propertyDetails.yml index c4b257ab49..221ab97b67 100644 --- a/src/main/resources/messages/propertyDetails.yml +++ b/src/main/resources/messages/propertyDetails.yml @@ -25,6 +25,9 @@ landlordDetails: expiredOn: 'Expired on {0}' sendNewInvitationEmail: Send a new invitation email removeFromList: Remove from list + removedBanner: + title: Success + content: Expired invitation removed. complianceInformation: heading: Compliance information certificateStatus: Certificate status diff --git a/src/main/resources/templates/propertyDetailsView.html b/src/main/resources/templates/propertyDetailsView.html index 67bb53af6d..3d09b96551 100644 --- a/src/main/resources/templates/propertyDetailsView.html +++ b/src/main/resources/templates/propertyDetailsView.html @@ -4,6 +4,16 @@ Back link
+ +
+

+ propertyDetails.landlordDetails.invitations.expiredInvitations.removedBanner.content +

+
+
@@ -133,8 +143,7 @@

propertyDetails.landlordDetails.invitations.expiredInvitations.sendNewInvitationEmail From 2627ee3ca66d052c4dba046181ca948a9f081510 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:13:25 +0100 Subject: [PATCH 3/3] PDJB-302: Add unit/controller tests --- .../PropertyDetailsControllerTests.kt | 29 ++++ .../JointLandlordInvitationServiceTests.kt | 142 ++++++++++++++++++ .../mockObjects/MockJointLandlordData.kt | 2 + 3 files changed, 173 insertions(+) diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/controllers/PropertyDetailsControllerTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/controllers/PropertyDetailsControllerTests.kt index ad706af380..68929efc76 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/controllers/PropertyDetailsControllerTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/controllers/PropertyDetailsControllerTests.kt @@ -184,4 +184,33 @@ class PropertyDetailsControllerTests( } } } + + @Nested + inner class RemoveExpiredInviteTests { + @Test + fun `removeExpiredInvite returns a redirect for an unauthenticated user`() { + mvc.get(PropertyDetailsController.getRemoveExpiredInvitePath(1L, 1L)).andExpect { + status { is3xxRedirection() } + } + } + + @Test + @WithMockUser + fun `removeExpiredInvite returns 403 for an unauthorized user`() { + mvc.get(PropertyDetailsController.getRemoveExpiredInvitePath(1L, 1L)).andExpect { + status { status { isForbidden() } } + } + } + + @Test + @WithMockUser(roles = ["LANDLORD"]) + fun `removeExpiredInvite redirects to property details with flash attribute on success`() { + mvc.get(PropertyDetailsController.getRemoveExpiredInvitePath(1L, 1L)).andExpect { + status { is3xxRedirection() } + flash { attribute("inviteRemoved", true) } + } + + verify(jointLandlordInvitationService).hideExpiredInvitation(eq(1L), any()) + } + } } diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationServiceTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationServiceTests.kt index a06bb02b0a..ef874bda7c 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationServiceTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationServiceTests.kt @@ -3,6 +3,8 @@ package uk.gov.communities.prsdb.webapp.services import jakarta.servlet.http.HttpSession import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -13,6 +15,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.whenever +import org.springframework.web.server.ResponseStatusException import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_TOKEN_WITH_ACCEPTANCE_JOURNEY_IDS import uk.gov.communities.prsdb.webapp.constants.USER_SENT_TO_LANDLORD_REGISTRATION_WHILE_ACCEPTING_JOINT_LANDLORD_INVITATION @@ -24,6 +27,7 @@ import uk.gov.communities.prsdb.webapp.testHelpers.mockObjects.MockLandlordData import java.net.URI import java.time.Instant import java.time.temporal.ChronoUnit +import java.util.Optional class JointLandlordInvitationServiceTests { private lateinit var mockJointLandlordInvitationRepository: JointLandlordInvitationRepository @@ -103,6 +107,32 @@ class JointLandlordInvitationServiceTests { assertEquals(olderInvitation, pending[1]) } + @Test + fun `getPendingAndExpiredInvitations excludes hidden invitations`() { + val propertyOwnership = MockLandlordData.createPropertyOwnership() + val pendingInvitation = + MockJointLandlordData.createJointLandlordInvitation( + propertyOwnership = propertyOwnership, + createdDate = Instant.now(), + ) + val hiddenInvitation = + MockJointLandlordData.createJointLandlordInvitation( + id = 456L, + propertyOwnership = propertyOwnership, + createdDate = Instant.now(), + isHidden = true, + ) + + whenever(mockJointLandlordInvitationRepository.findByRegisteredOwnership(propertyOwnership)) + .thenReturn(listOf(pendingInvitation, hiddenInvitation)) + + val (pending, expired) = invitationService.getPendingAndExpiredInvitations(propertyOwnership) + + assertEquals(1, pending.size) + assertEquals(pendingInvitation, pending[0]) + assertEquals(0, expired.size) + } + @Test fun `getPendingAndExpiredInvitations returns expired results sorted by createdDate descending`() { val propertyOwnership = MockLandlordData.createPropertyOwnership() @@ -129,6 +159,33 @@ class JointLandlordInvitationServiceTests { assertEquals(olderExpired, expired[1]) } + @Test + fun `getPendingAndExpiredInvitations excludes hidden expired invitations`() { + val propertyOwnership = MockLandlordData.createPropertyOwnership() + val expiredInvitation = + MockJointLandlordData.createJointLandlordInvitation( + id = 1L, + propertyOwnership = propertyOwnership, + createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 1).toLong(), ChronoUnit.DAYS), + ) + val hiddenExpiredInvitation = + MockJointLandlordData.createJointLandlordInvitation( + id = 2L, + propertyOwnership = propertyOwnership, + createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 1).toLong(), ChronoUnit.DAYS), + isHidden = true, + ) + + whenever(mockJointLandlordInvitationRepository.findByRegisteredOwnership(propertyOwnership)) + .thenReturn(listOf(expiredInvitation, hiddenExpiredInvitation)) + + val (pending, expired) = invitationService.getPendingAndExpiredInvitations(propertyOwnership) + + assertEquals(0, pending.size) + assertEquals(1, expired.size) + assertEquals(expiredInvitation, expired[0]) + } + @Test fun `getPendingAndExpiredInvitations makes a single repository call`() { val propertyOwnership = MockLandlordData.createPropertyOwnership() @@ -464,4 +521,89 @@ class JointLandlordInvitationServiceTests { assertNull(invitationService.getUserSentToLandlordRegistrationTaskFromSession("journey1")) } } + + @Nested + inner class HideExpiredInvitationTests { + private val baseUserId = "test-user-id" + + @Test + fun `hideExpiredInvitation sets isHidden to true and saves the invitation`() { + val baseUser = MockLandlordData.createPrsdbUser(baseUserId) + val landlord = MockLandlordData.createLandlord(baseUser = baseUser) + val propertyOwnership = MockLandlordData.createPropertyOwnership(primaryLandlord = landlord) + val invitation = + MockJointLandlordData.createJointLandlordInvitation( + id = 1L, + propertyOwnership = propertyOwnership, + createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 1).toLong(), ChronoUnit.DAYS), + ) + + whenever(mockJointLandlordInvitationRepository.findById(1L)) + .thenReturn(Optional.of(invitation)) + + invitationService.hideExpiredInvitation(1L, baseUserId) + + assertTrue(invitation.isHidden) + verify(mockJointLandlordInvitationRepository).save(invitation) + } + + @Test + fun `hideExpiredInvitation throws NOT_FOUND when invitation does not exist`() { + whenever(mockJointLandlordInvitationRepository.findById(999L)) + .thenReturn(Optional.empty()) + + val exception = + assertThrows(ResponseStatusException::class.java) { + invitationService.hideExpiredInvitation(999L, baseUserId) + } + + assertEquals(404, exception.statusCode.value()) + } + + @Test + fun `hideExpiredInvitation throws FORBIDDEN when user does not own the property`() { + val otherUser = MockLandlordData.createPrsdbUser("other-user-id") + val otherLandlord = MockLandlordData.createLandlord(baseUser = otherUser) + val propertyOwnership = MockLandlordData.createPropertyOwnership(primaryLandlord = otherLandlord) + val invitation = + MockJointLandlordData.createJointLandlordInvitation( + id = 1L, + propertyOwnership = propertyOwnership, + createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 1).toLong(), ChronoUnit.DAYS), + ) + + whenever(mockJointLandlordInvitationRepository.findById(1L)) + .thenReturn(Optional.of(invitation)) + + val exception = + assertThrows(ResponseStatusException::class.java) { + invitationService.hideExpiredInvitation(1L, baseUserId) + } + + assertEquals(403, exception.statusCode.value()) + } + + @Test + fun `hideExpiredInvitation throws BAD_REQUEST when invitation is not expired`() { + val baseUser = MockLandlordData.createPrsdbUser(baseUserId) + val landlord = MockLandlordData.createLandlord(baseUser = baseUser) + val propertyOwnership = MockLandlordData.createPropertyOwnership(primaryLandlord = landlord) + val invitation = + MockJointLandlordData.createJointLandlordInvitation( + id = 1L, + propertyOwnership = propertyOwnership, + createdDate = Instant.now(), + ) + + whenever(mockJointLandlordInvitationRepository.findById(1L)) + .thenReturn(Optional.of(invitation)) + + val exception = + assertThrows(ResponseStatusException::class.java) { + invitationService.hideExpiredInvitation(1L, baseUserId) + } + + assertEquals(400, exception.statusCode.value()) + } + } } diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/testHelpers/mockObjects/MockJointLandlordData.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/testHelpers/mockObjects/MockJointLandlordData.kt index 7362ef8e65..0441aa2fa5 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/testHelpers/mockObjects/MockJointLandlordData.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/testHelpers/mockObjects/MockJointLandlordData.kt @@ -18,6 +18,7 @@ class MockJointLandlordData { propertyOwnership: PropertyOwnership = MockLandlordData.createPropertyOwnership(), invitingLandlord: Landlord = MockLandlordData.createLandlord(), createdDate: Instant = Instant.now(), + isHidden: Boolean = false, ): JointLandlordInvitation { val jointLandlordInvitation = JointLandlordInvitation( @@ -29,6 +30,7 @@ class MockJointLandlordData { ) ReflectionTestUtils.setField(jointLandlordInvitation, "createdDate", createdDate) + jointLandlordInvitation.isHidden = isHidden return jointLandlordInvitation }