From f32b8872fd2e93ecc56c26347c1823285acfac28 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:36:32 +0100 Subject: [PATCH 1/9] PDJB-300: Add structure to get invitation details --- .../constants/InvitationLifetimeConstants.kt | 2 +- .../entity/JointLandlordInvitation.kt | 25 +++++++++++ .../JointLandlordInvitationRepository.kt | 3 ++ .../models/viewModels/InvitationViewModel.kt | 44 +++++++++++++++++++ .../JointLandlordInvitationService.kt | 12 +++++ 5 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/InvitationViewModel.kt diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/constants/InvitationLifetimeConstants.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/constants/InvitationLifetimeConstants.kt index c6a0469de7..65e27ce0f3 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/constants/InvitationLifetimeConstants.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/constants/InvitationLifetimeConstants.kt @@ -1,4 +1,4 @@ package uk.gov.communities.prsdb.webapp.constants const val LOCAL_COUNCIL_INVITATION_LIFETIME_IN_HOURS: Int = 48 -const val JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS: Int = 672 // 28 days +const val JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS: Int = 28 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 3c9376ce79..85c98ba343 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 @@ -7,6 +7,13 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.plus +import kotlinx.datetime.toKotlinInstant +import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS +import uk.gov.communities.prsdb.webapp.helpers.DateTimeHelper +import java.time.Instant +import java.time.temporal.ChronoUnit import java.util.UUID @Entity @@ -33,6 +40,24 @@ class JointLandlordInvitation( lateinit var invitingLandlord: Landlord private set + val expiryDate: Instant + get() = createdDate.plus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS) + + val daysUntilExpiry: Long + get() = ChronoUnit.DAYS.between(Instant.now(), expiryDate).coerceAtLeast(0) + + val isExpired: Boolean + get() { + val dateTimeHelper = DateTimeHelper() + + val expiresOnDate = + DateTimeHelper + .getDateInUK(createdDate.toKotlinInstant()) + .plus(DatePeriod(days = JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS)) + + return dateTimeHelper.getCurrentDateInUK() > expiresOnDate + } + constructor( token: UUID, email: String, diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/database/repository/JointLandlordInvitationRepository.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/database/repository/JointLandlordInvitationRepository.kt index ed458b01a5..d33ce3ec8b 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/database/repository/JointLandlordInvitationRepository.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/database/repository/JointLandlordInvitationRepository.kt @@ -2,8 +2,11 @@ package uk.gov.communities.prsdb.webapp.database.repository import org.springframework.data.jpa.repository.JpaRepository import uk.gov.communities.prsdb.webapp.database.entity.JointLandlordInvitation +import uk.gov.communities.prsdb.webapp.database.entity.PropertyOwnership import java.util.UUID interface JointLandlordInvitationRepository : JpaRepository { fun findByToken(token: UUID): JointLandlordInvitation? + + fun findByRegisteredOwnership(propertyOwnership: PropertyOwnership): List } 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 new file mode 100644 index 0000000000..5b2ec80b72 --- /dev/null +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/InvitationViewModel.kt @@ -0,0 +1,44 @@ +package uk.gov.communities.prsdb.webapp.models.viewModels + +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toKotlinInstant +import uk.gov.communities.prsdb.webapp.database.entity.JointLandlordInvitation +import uk.gov.communities.prsdb.webapp.helpers.DateTimeHelper +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.util.Locale + +data class PendingInvitationViewModel( + val email: String, + val expiresInDays: Long, + val expiryDate: String, + val sentDate: String, +) + +data class ExpiredInvitationViewModel( + val email: String, + val expiredDate: String, +) + +class InvitationViewModelBuilder { + companion object { + private val DATE_FORMATTER = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.UK) + + fun buildPendingViewModel(invitation: JointLandlordInvitation): PendingInvitationViewModel = + PendingInvitationViewModel( + email = invitation.invitedEmail, + expiresInDays = invitation.daysUntilExpiry, + expiryDate = formatInstant(invitation.expiryDate), + sentDate = formatInstant(invitation.createdDate), + ) + + fun buildExpiredViewModel(invitation: JointLandlordInvitation): ExpiredInvitationViewModel = + ExpiredInvitationViewModel( + email = invitation.invitedEmail, + expiredDate = formatInstant(invitation.expiryDate), + ) + + private fun formatInstant(instant: Instant): String = + DateTimeHelper.getDateInUK(instant.toKotlinInstant()).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 91b9163c6e..761211374b 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 @@ -17,6 +17,18 @@ class JointLandlordInvitationService( private val absoluteUrlProvider: AbsoluteUrlProvider, private val session: HttpSession, ) { + fun getPendingInvitations(propertyOwnership: PropertyOwnership): List = + invitationRepository + .findByRegisteredOwnership(propertyOwnership) + .filterNot { it.isExpired } + .sortedByDescending { it.createdDate } + + fun getExpiredInvitations(propertyOwnership: PropertyOwnership): List = + invitationRepository + .findByRegisteredOwnership(propertyOwnership) + .filter { it.isExpired } + .sortedByDescending { it.createdDate } + fun sendInvitationEmails( jointLandlordEmails: List, propertyOwnership: PropertyOwnership, From 3d721027545d57963b496d89a621e9dc043243bb Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:39:24 +0100 Subject: [PATCH 2/9] PDJB-300: Add invitations to frontend --- .../controllers/PropertyDetailsController.kt | 23 ++++++ .../resources/messages/propertyDetails.yml | 12 +++ .../templates/propertyDetailsView.html | 74 +++++++++++++++++++ .../PropertyDetailsControllerTests.kt | 8 ++ 4 files changed, 117 insertions(+) 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 0cfd9127c9..dd3d699aa1 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 @@ -12,7 +12,9 @@ import org.springframework.web.servlet.ModelAndView 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 +import uk.gov.communities.prsdb.webapp.config.managers.FeatureFlagManager import uk.gov.communities.prsdb.webapp.constants.COMPLIANCE_INFO_FRAGMENT +import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORDS 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 @@ -20,10 +22,12 @@ import uk.gov.communities.prsdb.webapp.constants.PROPERTY_DETAILS_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 +import uk.gov.communities.prsdb.webapp.models.viewModels.InvitationViewModelBuilder import uk.gov.communities.prsdb.webapp.models.viewModels.summaryModels.PropertyDetailsLandlordViewModelBuilder import uk.gov.communities.prsdb.webapp.models.viewModels.summaryModels.PropertyDetailsViewModel import uk.gov.communities.prsdb.webapp.models.viewModels.summaryModels.propertyComplianceViewModels.PropertyComplianceViewModelFactory import uk.gov.communities.prsdb.webapp.services.BackUrlStorageService +import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationService import uk.gov.communities.prsdb.webapp.services.PropertyComplianceService import uk.gov.communities.prsdb.webapp.services.PropertyOwnershipService import java.security.Principal @@ -36,6 +40,8 @@ class PropertyDetailsController( private val propertyComplianceService: PropertyComplianceService, private val propertyComplianceViewModelFactory: PropertyComplianceViewModelFactory, private val messageSource: MessageSource, + private val jointLandlordInvitationService: JointLandlordInvitationService, + private val featureFlagManager: FeatureFlagManager, ) { @PreAuthorize("hasRole('LANDLORD')") @GetMapping(LANDLORD_PROPERTY_DETAILS_ROUTE) @@ -82,6 +88,22 @@ class PropertyDetailsController( modelAndView.addObject("deregisterPropertyLink", DeregisterPropertyController.getPropertyDeregistrationPath(propertyOwnershipId)) modelAndView.addObject("isLandlordView", true) modelAndView.addObject("backUrl", LANDLORD_DASHBOARD_URL) + + val isJointLandlordsEnabled = featureFlagManager.checkFeature(JOINT_LANDLORDS) + modelAndView.addObject("isJointLandlordsEnabled", isJointLandlordsEnabled) + if (isJointLandlordsEnabled) { + val pendingInvitations = + jointLandlordInvitationService + .getPendingInvitations(propertyOwnership) + .map { InvitationViewModelBuilder.buildPendingViewModel(it) } + val expiredInvitations = + jointLandlordInvitationService + .getExpiredInvitations(propertyOwnership) + .map { InvitationViewModelBuilder.buildExpiredViewModel(it) } + modelAndView.addObject("pendingInvitations", pendingInvitations) + modelAndView.addObject("expiredInvitations", expiredInvitations) + } + return modelAndView } @@ -134,6 +156,7 @@ class PropertyDetailsController( model.addAttribute("complianceDetails", propertyComplianceDetails) model.addAttribute("complianceInfoTabId", COMPLIANCE_INFO_FRAGMENT) model.addAttribute("isLandlordView", false) + model.addAttribute("isJointLandlordsEnabled", false) model.addAttribute("backUrl", LOCAL_COUNCIL_DASHBOARD_URL) return "propertyDetailsView" diff --git a/src/main/resources/messages/propertyDetails.yml b/src/main/resources/messages/propertyDetails.yml index e264a4419d..d306af7e7a 100644 --- a/src/main/resources/messages/propertyDetails.yml +++ b/src/main/resources/messages/propertyDetails.yml @@ -12,6 +12,18 @@ landlordDetails: contactNumber: Contact number addressNonEnglandOrWales: Address (outside England or Wales) contactAddressInEnglandOrWales: Contact address in England or Wales + invitations: + pendingInvitations: + heading: 'Pending invitations ({0})' + expiresIn: 'Expires in {0} days ({1})' + sentOn: 'Sent on {0}' + sendNewInvitationEmail: Send a new email invitation + cancelInvitation: Cancel invitation + expiredInvitations: + heading: 'Expired invitations ({0})' + expiredOn: 'Expired on {0}' + sendNewInvitationEmail: Send a new invitation email + removeFromList: Remove from list complianceInformation: heading: Compliance information certificateStatus: Certificate status diff --git a/src/main/resources/templates/propertyDetailsView.html b/src/main/resources/templates/propertyDetailsView.html index 9eb4593fbf..90471d24e1 100644 --- a/src/main/resources/templates/propertyDetailsView.html +++ b/src/main/resources/templates/propertyDetailsView.html @@ -63,6 +63,80 @@

propertyDetails.landlordDetails.heading

propertyDetails.landlordDetails.registeredLandlord.heading

+ + +
+ + + propertyDetails.landlordDetails.invitations.pendingInvitations.heading + + +
+ +
+
+

invitation.email

+

+ propertyDetails.landlordDetails.invitations.pendingInvitations.expiresIn +

+

+ propertyDetails.landlordDetails.invitations.pendingInvitations.sentOn +

+ +
+
+
+
+ +
+ + + propertyDetails.landlordDetails.invitations.expiredInvitations.heading + + +
+ +
+
+

invitation.email

+

+ propertyDetails.landlordDetails.invitations.expiredInvitations.expiredOn +

+ +
+
+
+
+
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 251a4e575b..73853d93c8 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 @@ -10,7 +10,9 @@ import org.springframework.security.test.context.support.WithMockUser import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.get import org.springframework.web.context.WebApplicationContext +import uk.gov.communities.prsdb.webapp.config.managers.FeatureFlagManager import uk.gov.communities.prsdb.webapp.models.viewModels.summaryModels.propertyComplianceViewModels.PropertyComplianceViewModelFactory +import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationService import uk.gov.communities.prsdb.webapp.services.PropertyComplianceService import uk.gov.communities.prsdb.webapp.services.PropertyOwnershipService import uk.gov.communities.prsdb.webapp.testHelpers.mockObjects.MockLandlordData.Companion.createPropertyOwnership @@ -29,6 +31,12 @@ class PropertyDetailsControllerTests( @MockitoBean private lateinit var viewModelFactory: PropertyComplianceViewModelFactory + @MockitoBean + private lateinit var jointLandlordInvitationService: JointLandlordInvitationService + + @MockitoBean + private lateinit var featureFlagManager: FeatureFlagManager + @Nested inner class GetPropertyDetailsLandlordViewTests { @Test From e878a50dd62d4a87176c8fca3696444c0aa43e8b Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:16:44 +0100 Subject: [PATCH 3/9] PDJB-300: Add unit tests --- .../PropertyDetailsControllerTests.kt | 45 +++ .../JointLandlordInvitationServiceTests.kt | 269 +++++++++++++----- 2 files changed, 238 insertions(+), 76 deletions(-) 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 73853d93c8..7be3b85c77 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 @@ -3,6 +3,8 @@ package uk.gov.communities.prsdb.webapp.controllers import org.junit.jupiter.api.Nested import org.mockito.kotlin.any import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest @@ -11,6 +13,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.get import org.springframework.web.context.WebApplicationContext import uk.gov.communities.prsdb.webapp.config.managers.FeatureFlagManager +import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORDS import uk.gov.communities.prsdb.webapp.models.viewModels.summaryModels.propertyComplianceViewModels.PropertyComplianceViewModelFactory import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationService import uk.gov.communities.prsdb.webapp.services.PropertyComplianceService @@ -84,6 +87,48 @@ class PropertyDetailsControllerTests( status { status { isOk() } } } } + + @Test + @WithMockUser(roles = ["LANDLORD"]) + fun `getPropertyDetails fetches invitations when joint landlords feature is enabled`() { + val propertyOwnership = createPropertyOwnership() + + whenever(propertyOwnershipService.getPropertyOwnershipIfAuthorizedUser(eq(propertyOwnership.id), any())) + .thenReturn(propertyOwnership) + whenever(featureFlagManager.checkFeature(JOINT_LANDLORDS)).thenReturn(true) + whenever(jointLandlordInvitationService.getPendingInvitations(propertyOwnership)).thenReturn(emptyList()) + whenever(jointLandlordInvitationService.getExpiredInvitations(propertyOwnership)).thenReturn(emptyList()) + + mvc.get(PropertyDetailsController.getPropertyDetailsPath(propertyOwnership.id, isLocalCouncilView = false)).andExpect { + status { isOk() } + model { attribute("isJointLandlordsEnabled", true) } + model { attributeExists("pendingInvitations") } + model { attributeExists("expiredInvitations") } + } + + verify(jointLandlordInvitationService).getPendingInvitations(propertyOwnership) + verify(jointLandlordInvitationService).getExpiredInvitations(propertyOwnership) + } + + @Test + @WithMockUser(roles = ["LANDLORD"]) + fun `getPropertyDetails does not fetch invitations when joint landlords feature is disabled`() { + val propertyOwnership = createPropertyOwnership() + + whenever(propertyOwnershipService.getPropertyOwnershipIfAuthorizedUser(eq(propertyOwnership.id), any())) + .thenReturn(propertyOwnership) + whenever(featureFlagManager.checkFeature(JOINT_LANDLORDS)).thenReturn(false) + + mvc.get(PropertyDetailsController.getPropertyDetailsPath(propertyOwnership.id, isLocalCouncilView = false)).andExpect { + status { isOk() } + model { attribute("isJointLandlordsEnabled", false) } + model { attributeDoesNotExist("pendingInvitations") } + model { attributeDoesNotExist("expiredInvitations") } + } + + verify(jointLandlordInvitationService, never()).getPendingInvitations(any()) + verify(jointLandlordInvitationService, never()).getExpiredInvitations(any()) + } } @Nested 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 3acafb5629..77f6845aa1 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 @@ -2,6 +2,7 @@ package uk.gov.communities.prsdb.webapp.services import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.mockito.Mockito.mock import org.mockito.Mockito.times @@ -11,12 +12,16 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.whenever import org.springframework.mock.web.MockHttpSession +import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_TOKEN import uk.gov.communities.prsdb.webapp.database.entity.Landlord import uk.gov.communities.prsdb.webapp.database.repository.JointLandlordInvitationRepository import uk.gov.communities.prsdb.webapp.models.viewModels.emailModels.JointLandlordInvitationEmail +import uk.gov.communities.prsdb.webapp.testHelpers.mockObjects.MockJointLandlordData import uk.gov.communities.prsdb.webapp.testHelpers.mockObjects.MockLandlordData import java.net.URI +import java.time.Instant +import java.time.temporal.ChronoUnit class JointLandlordInvitationServiceTests { private lateinit var mockJointLandlordInvitationRepository: JointLandlordInvitationRepository @@ -42,109 +47,221 @@ class JointLandlordInvitationServiceTests { invitingLandlord = MockLandlordData.createLandlord() } - @Test - fun `sendInvitationEmails creates invitation tokens for each email address`() { - val jointLandlordEmails = listOf("landlord1@example.com", "landlord2@example.com", "landlord3@example.com") - val propertyOwnership = MockLandlordData.createPropertyOwnership() - val mockToken = "test-token-123" - val mockUri = URI("https://example.com/invite/$mockToken") + @Nested + inner class GetPendingInvitationsTests { + @Test + fun `getPendingInvitations returns only non-expired invitations`() { + val propertyOwnership = MockLandlordData.createPropertyOwnership() + val pendingInvitation = + MockJointLandlordData.createJointLandlordInvitation( + propertyOwnership = propertyOwnership, + createdDate = Instant.now(), + ) + val expiredInvitation = + MockJointLandlordData.createJointLandlordInvitation( + id = 456L, + propertyOwnership = propertyOwnership, + createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 1).toLong(), ChronoUnit.DAYS), + ) - whenever(mockAbsoluteUrlProvider.buildJointLandlordInvitationUri(any())) - .thenReturn(mockUri) + whenever(mockJointLandlordInvitationRepository.findByRegisteredOwnership(propertyOwnership)) + .thenReturn(listOf(pendingInvitation, expiredInvitation)) - invitationService.sendInvitationEmails(jointLandlordEmails, propertyOwnership, invitingLandlord) + val result = invitationService.getPendingInvitations(propertyOwnership) - verify(mockJointLandlordInvitationRepository, times(3)).save(any()) + assertEquals(1, result.size) + assertEquals(pendingInvitation, result[0]) + } + + @Test + fun `getPendingInvitations returns results sorted by createdDate descending`() { + val propertyOwnership = MockLandlordData.createPropertyOwnership() + val olderInvitation = + MockJointLandlordData.createJointLandlordInvitation( + id = 1L, + propertyOwnership = propertyOwnership, + createdDate = Instant.now().minus(10, ChronoUnit.DAYS), + ) + val newerInvitation = + MockJointLandlordData.createJointLandlordInvitation( + id = 2L, + propertyOwnership = propertyOwnership, + createdDate = Instant.now().minus(1, ChronoUnit.DAYS), + ) + + whenever(mockJointLandlordInvitationRepository.findByRegisteredOwnership(propertyOwnership)) + .thenReturn(listOf(olderInvitation, newerInvitation)) + + val result = invitationService.getPendingInvitations(propertyOwnership) + + assertEquals(2, result.size) + assertEquals(newerInvitation, result[0]) + assertEquals(olderInvitation, result[1]) + } } - @Test - fun `sendInvitationEmails sends an email to each joint landlord`() { - val jointLandlordEmails = listOf("landlord1@example.com", "landlord2@example.com") - val propertyOwnership = MockLandlordData.createPropertyOwnership() - val mockUri = URI("https://example.com/invite/test-token") + @Nested + inner class GetExpiredInvitationsTests { + @Test + fun `getExpiredInvitations returns only expired invitations`() { + val propertyOwnership = MockLandlordData.createPropertyOwnership() + val pendingInvitation = + MockJointLandlordData.createJointLandlordInvitation( + propertyOwnership = propertyOwnership, + createdDate = Instant.now(), + ) + val expiredInvitation = + MockJointLandlordData.createJointLandlordInvitation( + id = 456L, + propertyOwnership = propertyOwnership, + createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 1).toLong(), ChronoUnit.DAYS), + ) + + whenever(mockJointLandlordInvitationRepository.findByRegisteredOwnership(propertyOwnership)) + .thenReturn(listOf(pendingInvitation, expiredInvitation)) + + val result = invitationService.getExpiredInvitations(propertyOwnership) - whenever(mockAbsoluteUrlProvider.buildJointLandlordInvitationUri(any())) - .thenReturn(mockUri) + assertEquals(1, result.size) + assertEquals(expiredInvitation, result[0]) + } - invitationService.sendInvitationEmails(jointLandlordEmails, propertyOwnership, invitingLandlord) + @Test + fun `getExpiredInvitations returns results sorted by createdDate descending`() { + val propertyOwnership = MockLandlordData.createPropertyOwnership() + val olderExpired = + MockJointLandlordData.createJointLandlordInvitation( + id = 1L, + propertyOwnership = propertyOwnership, + createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 32).toLong(), ChronoUnit.DAYS), + ) + val newerExpired = + MockJointLandlordData.createJointLandlordInvitation( + id = 2L, + propertyOwnership = propertyOwnership, + createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 2).toLong(), ChronoUnit.DAYS), + ) - val emailCaptor = argumentCaptor() - verify(mockEmailNotificationService, times(2)) - .sendEmail(emailCaptor.capture(), any()) + whenever(mockJointLandlordInvitationRepository.findByRegisteredOwnership(propertyOwnership)) + .thenReturn(listOf(olderExpired, newerExpired)) - assertEquals(jointLandlordEmails, emailCaptor.allValues) + val result = invitationService.getExpiredInvitations(propertyOwnership) + + assertEquals(2, result.size) + assertEquals(newerExpired, result[0]) + assertEquals(olderExpired, result[1]) + } } - @Test - fun `sendInvitationEmails includes correct sender name and property address in email`() { - val jointLandlordEmails = listOf("landlord1@example.com") - val landlord = MockLandlordData.createLandlord(name = "John Smith") - val address = MockLandlordData.createAddress(singleLineAddress = "123 Test Street, London, SW1A 1AA") - val propertyOwnership = - MockLandlordData.createPropertyOwnership( - primaryLandlord = landlord, - address = address, - ) - val mockUri = URI("https://example.com/invite/test-token") + @Nested + inner class SendInvitationEmailsTests { + @Test + fun `sendInvitationEmails creates invitation tokens for each email address`() { + val jointLandlordEmails = listOf("landlord1@example.com", "landlord2@example.com", "landlord3@example.com") + val propertyOwnership = MockLandlordData.createPropertyOwnership() + val mockToken = "test-token-123" + val mockUri = URI("https://example.com/invite/$mockToken") - whenever(mockAbsoluteUrlProvider.buildJointLandlordInvitationUri(any())) - .thenReturn(mockUri) + whenever(mockAbsoluteUrlProvider.buildJointLandlordInvitationUri(any())) + .thenReturn(mockUri) - invitationService.sendInvitationEmails(jointLandlordEmails, propertyOwnership, landlord) + invitationService.sendInvitationEmails(jointLandlordEmails, propertyOwnership, invitingLandlord) - val emailModelCaptor = argumentCaptor() - verify(mockEmailNotificationService) - .sendEmail(eq("landlord1@example.com"), emailModelCaptor.capture()) + verify(mockJointLandlordInvitationRepository, times(3)).save(any()) + } - assertEquals("John Smith", emailModelCaptor.firstValue.senderName) - assertEquals("123 Test Street\nLondon\nSW1A 1AA", emailModelCaptor.firstValue.propertyAddress) - assertEquals(mockUri, emailModelCaptor.firstValue.invitationUri) - } + @Test + fun `sendInvitationEmails sends an email to each joint landlord`() { + val jointLandlordEmails = listOf("landlord1@example.com", "landlord2@example.com") + val propertyOwnership = MockLandlordData.createPropertyOwnership() + val mockUri = URI("https://example.com/invite/test-token") - @Test - fun `sendInvitationEmails creates unique tokens for each invitation`() { - val jointLandlordEmails = listOf("landlord1@example.com", "landlord2@example.com") - val propertyOwnership = MockLandlordData.createPropertyOwnership() - val mockUri1 = URI("https://example.com/invite/token-1") - val mockUri2 = URI("https://example.com/invite/token-2") + whenever(mockAbsoluteUrlProvider.buildJointLandlordInvitationUri(any())) + .thenReturn(mockUri) - whenever(mockAbsoluteUrlProvider.buildJointLandlordInvitationUri(any())) - .thenReturn(mockUri1, mockUri2) + invitationService.sendInvitationEmails(jointLandlordEmails, propertyOwnership, invitingLandlord) - invitationService.sendInvitationEmails(jointLandlordEmails, propertyOwnership, invitingLandlord) + val emailCaptor = argumentCaptor() + verify(mockEmailNotificationService, times(2)) + .sendEmail(emailCaptor.capture(), any()) - verify(mockJointLandlordInvitationRepository, times(2)).save(any()) - verify(mockAbsoluteUrlProvider, times(2)).buildJointLandlordInvitationUri(any()) - } + assertEquals(jointLandlordEmails, emailCaptor.allValues) + } - @Test - fun `sendInvitationEmails handles empty list without error`() { - val jointLandlordEmails = emptyList() - val propertyOwnership = MockLandlordData.createPropertyOwnership() + @Test + fun `sendInvitationEmails includes correct sender name and property address in email`() { + val jointLandlordEmails = listOf("landlord1@example.com") + val landlord = MockLandlordData.createLandlord(name = "John Smith") + val address = MockLandlordData.createAddress(singleLineAddress = "123 Test Street, London, SW1A 1AA") + val propertyOwnership = + MockLandlordData.createPropertyOwnership( + primaryLandlord = landlord, + address = address, + ) + val mockUri = URI("https://example.com/invite/test-token") - invitationService.sendInvitationEmails(jointLandlordEmails, propertyOwnership, invitingLandlord) + whenever(mockAbsoluteUrlProvider.buildJointLandlordInvitationUri(any())) + .thenReturn(mockUri) - verify(mockJointLandlordInvitationRepository, times(0)).save(any()) - verify(mockEmailNotificationService, times(0)).sendEmail(any(), any()) - } + invitationService.sendInvitationEmails(jointLandlordEmails, propertyOwnership, landlord) - @Test - fun `storeTokenInSession stores the token under JOINT_LANDLORD_INVITATION_TOKEN`() { - invitationService.storeTokenInSession("test-token-123") + val emailModelCaptor = argumentCaptor() + verify(mockEmailNotificationService) + .sendEmail(eq("landlord1@example.com"), emailModelCaptor.capture()) - verify(mockHttpSession).setAttribute(JOINT_LANDLORD_INVITATION_TOKEN, "test-token-123") - } + assertEquals("John Smith", emailModelCaptor.firstValue.senderName) + assertEquals("123 Test Street\nLondon\nSW1A 1AA", emailModelCaptor.firstValue.propertyAddress) + assertEquals(mockUri, emailModelCaptor.firstValue.invitationUri) + } + + @Test + fun `sendInvitationEmails creates unique tokens for each invitation`() { + val jointLandlordEmails = listOf("landlord1@example.com", "landlord2@example.com") + val propertyOwnership = MockLandlordData.createPropertyOwnership() + val mockUri1 = URI("https://example.com/invite/token-1") + val mockUri2 = URI("https://example.com/invite/token-2") + + whenever(mockAbsoluteUrlProvider.buildJointLandlordInvitationUri(any())) + .thenReturn(mockUri1, mockUri2) + + invitationService.sendInvitationEmails(jointLandlordEmails, propertyOwnership, invitingLandlord) + + verify(mockJointLandlordInvitationRepository, times(2)).save(any()) + verify(mockAbsoluteUrlProvider, times(2)).buildJointLandlordInvitationUri(any()) + } - @Test - fun `getTokenFromSession retrieves the value under JOINT_LANDLORD_INVITATION_TOKEN`() { - invitationService.getTokenFromSession() + @Test + fun `sendInvitationEmails handles empty list without error`() { + val jointLandlordEmails = emptyList() + val propertyOwnership = MockLandlordData.createPropertyOwnership() - verify(mockHttpSession).getAttribute(JOINT_LANDLORD_INVITATION_TOKEN) + invitationService.sendInvitationEmails(jointLandlordEmails, propertyOwnership, invitingLandlord) + + verify(mockJointLandlordInvitationRepository, times(0)).save(any()) + verify(mockEmailNotificationService, times(0)).sendEmail(any(), any()) + } } - @Test - fun `clearTokenFromSession clears JOINT_LANDLORD_INVITATION_TOKEN`() { - invitationService.clearTokenFromSession() - verify(mockHttpSession).removeAttribute(JOINT_LANDLORD_INVITATION_TOKEN) + @Nested + inner class SessionTokenTests { + @Test + fun `storeTokenInSession stores the token under JOINT_LANDLORD_INVITATION_TOKEN`() { + invitationService.storeTokenInSession("test-token-123") + + verify(mockHttpSession).setAttribute(JOINT_LANDLORD_INVITATION_TOKEN, "test-token-123") + } + + @Test + fun `getTokenFromSession retrieves the value under JOINT_LANDLORD_INVITATION_TOKEN`() { + invitationService.getTokenFromSession() + + verify(mockHttpSession).getAttribute(JOINT_LANDLORD_INVITATION_TOKEN) + } + + @Test + fun `clearTokenFromSession clears JOINT_LANDLORD_INVITATION_TOKEN`() { + invitationService.clearTokenFromSession() + verify(mockHttpSession).removeAttribute(JOINT_LANDLORD_INVITATION_TOKEN) + } } } From 856ec343800873ce8a21eb95d7dd50e8537c3aed Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:25:56 +0100 Subject: [PATCH 4/9] PDJB-300: Add integration test --- .../integration/PropertyDetailsTests.kt | 57 +++++++++++++++++++ .../pages/PropertyDetailsPageLandlordView.kt | 7 +++ .../data-joint-landlord-invitation.sql | 3 +- 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/integration/PropertyDetailsTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/integration/PropertyDetailsTests.kt index ef3ba84daa..c029b56092 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/integration/PropertyDetailsTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/integration/PropertyDetailsTests.kt @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import uk.gov.communities.prsdb.webapp.constants.COMPLIANCE_ACTIONS_MAY2026_REDESIGN import uk.gov.communities.prsdb.webapp.constants.COMPLIANCE_INFO_FRAGMENT +import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORDS import uk.gov.communities.prsdb.webapp.constants.LANDLORD_DETAILS_FRAGMENT import uk.gov.communities.prsdb.webapp.integration.pageObjects.components.BaseComponent.Companion.assertThat import uk.gov.communities.prsdb.webapp.integration.pageObjects.pages.LandlordDashboardPage @@ -343,4 +344,60 @@ class PropertyDetailsTests : IntegrationTestWithImmutableData("data-local.sql") } } } + + @Nested + inner class PropertyDetailsInvitations : NestedIntegrationTestWithImmutableData("data-joint-landlord-invitation.sql") { + @BeforeEach + fun enableJointLandlordsFlag() { + FeatureFlagConfigUpdater(featureFlagManager).enableUnreleasedFeature(JOINT_LANDLORDS) + } + + @Test + fun `property details page shows pending invitations section with correct email`(page: Page) { + val detailsPage = navigator.goToPropertyDetailsLandlordView(1) + detailsPage.tabs.goToLandlordDetails() + + assertThat(detailsPage.pendingInvitationsDetails).isVisible() + assertThat(detailsPage.pendingInvitationsDetails).containsText("Pending invitations (1)") + assertThat(detailsPage.pendingInvitationsDetails).containsText("pending@example.com") + } + + @Test + fun `property details page shows expired invitations section with correct email`(page: Page) { + val detailsPage = navigator.goToPropertyDetailsLandlordView(1) + detailsPage.tabs.goToLandlordDetails() + + assertThat(detailsPage.expiredInvitationsDetails).isVisible() + assertThat(detailsPage.expiredInvitationsDetails).containsText("Expired invitations (1)") + assertThat(detailsPage.expiredInvitationsDetails).containsText("expired@example.com") + } + + @Test + fun `pending invitation shows expiry and sent date details`(page: Page) { + val detailsPage = navigator.goToPropertyDetailsLandlordView(1) + detailsPage.tabs.goToLandlordDetails() + + assertThat(detailsPage.pendingInvitationsDetails).containsText("Expires in") + assertThat(detailsPage.pendingInvitationsDetails).containsText("Sent on") + } + + @Test + fun `expired invitation shows expired date`(page: Page) { + val detailsPage = navigator.goToPropertyDetailsLandlordView(1) + detailsPage.tabs.goToLandlordDetails() + + assertThat(detailsPage.expiredInvitationsDetails).containsText("Expired on") + } + + @Test + fun `invitation sections are not shown when feature flag is disabled`(page: Page) { + featureFlagManager.disableFeature(JOINT_LANDLORDS) + + val detailsPage = navigator.goToPropertyDetailsLandlordView(1) + detailsPage.tabs.goToLandlordDetails() + + assertThat(detailsPage.pendingInvitationsDetails).hasCount(0) + assertThat(detailsPage.expiredInvitationsDetails).hasCount(0) + } + } } diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/integration/pageObjects/pages/PropertyDetailsPageLandlordView.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/integration/pageObjects/pages/PropertyDetailsPageLandlordView.kt index 7db13a7ac5..74d710cd5e 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/integration/pageObjects/pages/PropertyDetailsPageLandlordView.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/integration/pageObjects/pages/PropertyDetailsPageLandlordView.kt @@ -1,5 +1,6 @@ package uk.gov.communities.prsdb.webapp.integration.pageObjects.pages +import com.microsoft.playwright.Locator import com.microsoft.playwright.Page import uk.gov.communities.prsdb.webapp.controllers.PropertyDetailsController import uk.gov.communities.prsdb.webapp.integration.pageObjects.components.Link @@ -20,6 +21,12 @@ class PropertyDetailsPageLandlordView( val notificationBanner = NotificationBannerPropertyDetailsLandlordView(page) + val pendingInvitationsDetails: Locator + get() = page.locator("details", Page.LocatorOptions().setHasText("Pending invitations")) + + val expiredInvitationsDetails: Locator + get() = page.locator("details", Page.LocatorOptions().setHasText("Expired invitations")) + class NotificationBannerPropertyDetailsLandlordView( page: Page, ) : NotificationBanner(page) { diff --git a/src/test/resources/data-joint-landlord-invitation.sql b/src/test/resources/data-joint-landlord-invitation.sql index 22e2c3b5e9..9351da5529 100644 --- a/src/test/resources/data-joint-landlord-invitation.sql +++ b/src/test/resources/data-joint-landlord-invitation.sql @@ -26,5 +26,6 @@ VALUES (1, true, 1, 1, 2, 2, 1, 2, current_date, 1, SELECT setval(pg_get_serial_sequence('property_ownership', 'id'), (SELECT MAX(id) FROM property_ownership)); INSERT INTO joint_landlord_invitation (id, invited_email, registered_propertyid, token, inviting_landlord_id, created_date) -VALUES (1, 'invited@example.com', 1, 'aaaabbbb-cccc-dddd-eeee-ffff00001111', 1,'05/05/2025'); +VALUES (1, 'expired@example.com', 1, 'aaaabbbb-cccc-dddd-eeee-ffff00001111', 1, '01/01/2025'), + (2, 'pending@example.com', 1, 'aaaabbbb-cccc-dddd-eeee-ffff00002222', 1, current_timestamp); SELECT setval(pg_get_serial_sequence('joint_landlord_invitation', 'id'), (SELECT MAX(id) FROM joint_landlord_invitation)); From b5f3f460b9669284fac7dd49c084ce891d8cce47 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:23:47 +0100 Subject: [PATCH 5/9] PDJB-300: Use a partition on the invite database query this means we only query the database once avoiding a potential race condition --- .../controllers/PropertyDetailsController.kt | 15 ++-- .../JointLandlordInvitationService.kt | 21 +++--- .../PropertyDetailsControllerTests.kt | 10 ++- .../JointLandlordInvitationServiceTests.kt | 69 ++++++++----------- 4 files changed, 50 insertions(+), 65 deletions(-) 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 dd3d699aa1..37f288cd16 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 @@ -92,14 +92,15 @@ class PropertyDetailsController( val isJointLandlordsEnabled = featureFlagManager.checkFeature(JOINT_LANDLORDS) modelAndView.addObject("isJointLandlordsEnabled", isJointLandlordsEnabled) if (isJointLandlordsEnabled) { - val pendingInvitations = + val (pendingInvitations, expiredInvitations) = jointLandlordInvitationService - .getPendingInvitations(propertyOwnership) - .map { InvitationViewModelBuilder.buildPendingViewModel(it) } - val expiredInvitations = - jointLandlordInvitationService - .getExpiredInvitations(propertyOwnership) - .map { InvitationViewModelBuilder.buildExpiredViewModel(it) } + .getPendingAndExpiredInvitations(propertyOwnership) + .let { (pending, expired) -> + Pair( + pending.map { InvitationViewModelBuilder.buildPendingViewModel(it) }, + expired.map { InvitationViewModelBuilder.buildExpiredViewModel(it) }, + ) + } modelAndView.addObject("pendingInvitations", pendingInvitations) modelAndView.addObject("expiredInvitations", expiredInvitations) } 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 761211374b..71dab9dbb5 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 @@ -17,17 +17,16 @@ class JointLandlordInvitationService( private val absoluteUrlProvider: AbsoluteUrlProvider, private val session: HttpSession, ) { - fun getPendingInvitations(propertyOwnership: PropertyOwnership): List = - invitationRepository - .findByRegisteredOwnership(propertyOwnership) - .filterNot { it.isExpired } - .sortedByDescending { it.createdDate } - - fun getExpiredInvitations(propertyOwnership: PropertyOwnership): List = - invitationRepository - .findByRegisteredOwnership(propertyOwnership) - .filter { it.isExpired } - .sortedByDescending { it.createdDate } + fun getPendingAndExpiredInvitations( + propertyOwnership: PropertyOwnership, + ): Pair, List> { + val (expired, pending) = + invitationRepository + .findByRegisteredOwnership(propertyOwnership) + .sortedByDescending { it.createdDate } + .partition { it.isExpired } + return Pair(pending, expired) // flips the above pair from expired, pending to pending, expired + } fun sendInvitationEmails( jointLandlordEmails: List, 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 7be3b85c77..ad706af380 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 @@ -96,8 +96,8 @@ class PropertyDetailsControllerTests( whenever(propertyOwnershipService.getPropertyOwnershipIfAuthorizedUser(eq(propertyOwnership.id), any())) .thenReturn(propertyOwnership) whenever(featureFlagManager.checkFeature(JOINT_LANDLORDS)).thenReturn(true) - whenever(jointLandlordInvitationService.getPendingInvitations(propertyOwnership)).thenReturn(emptyList()) - whenever(jointLandlordInvitationService.getExpiredInvitations(propertyOwnership)).thenReturn(emptyList()) + whenever(jointLandlordInvitationService.getPendingAndExpiredInvitations(propertyOwnership)) + .thenReturn(Pair(emptyList(), emptyList())) mvc.get(PropertyDetailsController.getPropertyDetailsPath(propertyOwnership.id, isLocalCouncilView = false)).andExpect { status { isOk() } @@ -106,8 +106,7 @@ class PropertyDetailsControllerTests( model { attributeExists("expiredInvitations") } } - verify(jointLandlordInvitationService).getPendingInvitations(propertyOwnership) - verify(jointLandlordInvitationService).getExpiredInvitations(propertyOwnership) + verify(jointLandlordInvitationService).getPendingAndExpiredInvitations(propertyOwnership) } @Test @@ -126,8 +125,7 @@ class PropertyDetailsControllerTests( model { attributeDoesNotExist("expiredInvitations") } } - verify(jointLandlordInvitationService, never()).getPendingInvitations(any()) - verify(jointLandlordInvitationService, never()).getExpiredInvitations(any()) + verify(jointLandlordInvitationService, never()).getPendingAndExpiredInvitations(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 77f6845aa1..784779f2f2 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 @@ -48,9 +48,9 @@ class JointLandlordInvitationServiceTests { } @Nested - inner class GetPendingInvitationsTests { + inner class GetPendingAndExpiredInvitationsTests { @Test - fun `getPendingInvitations returns only non-expired invitations`() { + fun `getPendingAndExpiredInvitations partitions into pending and expired`() { val propertyOwnership = MockLandlordData.createPropertyOwnership() val pendingInvitation = MockJointLandlordData.createJointLandlordInvitation( @@ -67,14 +67,16 @@ class JointLandlordInvitationServiceTests { whenever(mockJointLandlordInvitationRepository.findByRegisteredOwnership(propertyOwnership)) .thenReturn(listOf(pendingInvitation, expiredInvitation)) - val result = invitationService.getPendingInvitations(propertyOwnership) + val (pending, expired) = invitationService.getPendingAndExpiredInvitations(propertyOwnership) - assertEquals(1, result.size) - assertEquals(pendingInvitation, result[0]) + assertEquals(1, pending.size) + assertEquals(pendingInvitation, pending[0]) + assertEquals(1, expired.size) + assertEquals(expiredInvitation, expired[0]) } @Test - fun `getPendingInvitations returns results sorted by createdDate descending`() { + fun `getPendingAndExpiredInvitations returns pending results sorted by createdDate descending`() { val propertyOwnership = MockLandlordData.createPropertyOwnership() val olderInvitation = MockJointLandlordData.createJointLandlordInvitation( @@ -92,64 +94,49 @@ class JointLandlordInvitationServiceTests { whenever(mockJointLandlordInvitationRepository.findByRegisteredOwnership(propertyOwnership)) .thenReturn(listOf(olderInvitation, newerInvitation)) - val result = invitationService.getPendingInvitations(propertyOwnership) + val (pending, _) = invitationService.getPendingAndExpiredInvitations(propertyOwnership) - assertEquals(2, result.size) - assertEquals(newerInvitation, result[0]) - assertEquals(olderInvitation, result[1]) + assertEquals(2, pending.size) + assertEquals(newerInvitation, pending[0]) + assertEquals(olderInvitation, pending[1]) } - } - @Nested - inner class GetExpiredInvitationsTests { @Test - fun `getExpiredInvitations returns only expired invitations`() { + fun `getPendingAndExpiredInvitations returns expired results sorted by createdDate descending`() { val propertyOwnership = MockLandlordData.createPropertyOwnership() - val pendingInvitation = + val olderExpired = MockJointLandlordData.createJointLandlordInvitation( + id = 1L, propertyOwnership = propertyOwnership, - createdDate = Instant.now(), + createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 32).toLong(), ChronoUnit.DAYS), ) - val expiredInvitation = + val newerExpired = MockJointLandlordData.createJointLandlordInvitation( - id = 456L, + id = 2L, propertyOwnership = propertyOwnership, - createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 1).toLong(), ChronoUnit.DAYS), + createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 2).toLong(), ChronoUnit.DAYS), ) whenever(mockJointLandlordInvitationRepository.findByRegisteredOwnership(propertyOwnership)) - .thenReturn(listOf(pendingInvitation, expiredInvitation)) + .thenReturn(listOf(olderExpired, newerExpired)) - val result = invitationService.getExpiredInvitations(propertyOwnership) + val (_, expired) = invitationService.getPendingAndExpiredInvitations(propertyOwnership) - assertEquals(1, result.size) - assertEquals(expiredInvitation, result[0]) + assertEquals(2, expired.size) + assertEquals(newerExpired, expired[0]) + assertEquals(olderExpired, expired[1]) } @Test - fun `getExpiredInvitations returns results sorted by createdDate descending`() { + fun `getPendingAndExpiredInvitations makes a single repository call`() { val propertyOwnership = MockLandlordData.createPropertyOwnership() - val olderExpired = - MockJointLandlordData.createJointLandlordInvitation( - id = 1L, - propertyOwnership = propertyOwnership, - createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 32).toLong(), ChronoUnit.DAYS), - ) - val newerExpired = - MockJointLandlordData.createJointLandlordInvitation( - id = 2L, - propertyOwnership = propertyOwnership, - createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 2).toLong(), ChronoUnit.DAYS), - ) whenever(mockJointLandlordInvitationRepository.findByRegisteredOwnership(propertyOwnership)) - .thenReturn(listOf(olderExpired, newerExpired)) + .thenReturn(emptyList()) - val result = invitationService.getExpiredInvitations(propertyOwnership) + invitationService.getPendingAndExpiredInvitations(propertyOwnership) - assertEquals(2, result.size) - assertEquals(newerExpired, result[0]) - assertEquals(olderExpired, result[1]) + verify(mockJointLandlordInvitationRepository, times(1)).findByRegisteredOwnership(propertyOwnership) } } From d3777f91e2315d95a2fd5c7156c734a5a21ff9a2 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:40:48 +0100 Subject: [PATCH 6/9] PDJB-300: Add singular expires on option --- src/main/resources/messages/propertyDetails.yml | 1 + src/main/resources/templates/propertyDetailsView.html | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/src/main/resources/messages/propertyDetails.yml b/src/main/resources/messages/propertyDetails.yml index d306af7e7a..c4b257ab49 100644 --- a/src/main/resources/messages/propertyDetails.yml +++ b/src/main/resources/messages/propertyDetails.yml @@ -16,6 +16,7 @@ landlordDetails: pendingInvitations: heading: 'Pending invitations ({0})' expiresIn: 'Expires in {0} days ({1})' + expiresInSingular: 'Expires in 1 day ({0})' sentOn: 'Sent on {0}' sendNewInvitationEmail: Send a new email invitation cancelInvitation: Cancel invitation diff --git a/src/main/resources/templates/propertyDetailsView.html b/src/main/resources/templates/propertyDetailsView.html index 90471d24e1..b283587b55 100644 --- a/src/main/resources/templates/propertyDetailsView.html +++ b/src/main/resources/templates/propertyDetailsView.html @@ -78,9 +78,15 @@

invitation.email

propertyDetails.landlordDetails.invitations.pendingInvitations.expiresIn

+

+ propertyDetails.landlordDetails.invitations.pendingInvitations.expiresInSingular +

propertyDetails.landlordDetails.invitations.pendingInvitations.sentOn From 16e86a963001abcf6668cad4383d5ef38775f4c6 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:02:23 +0100 Subject: [PATCH 7/9] PDJB-300: Implement custom css for link groups --- src/main/resources/css/_linkGroupList.scss | 19 +++++++++++++++++++ src/main/resources/css/custom.scss | 1 + .../templates/propertyDetailsView.html | 12 ++++++------ 3 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 src/main/resources/css/_linkGroupList.scss diff --git a/src/main/resources/css/_linkGroupList.scss b/src/main/resources/css/_linkGroupList.scss new file mode 100644 index 0000000000..5aec2ae416 --- /dev/null +++ b/src/main/resources/css/_linkGroupList.scss @@ -0,0 +1,19 @@ +@import "govuk/base"; + +.prsdb-link-group-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + + .prsdb-link-group-item:first-child { + padding: 0; + margin: 0; + } + + .prsdb-link-group-item:not(:first-child) { + margin-left: govuk-spacing(2); + padding-left: govuk-spacing(2); + border-left: 1px solid govuk-colour("mid-grey"); + } +} diff --git a/src/main/resources/css/custom.scss b/src/main/resources/css/custom.scss index b530fee735..395d68505e 100644 --- a/src/main/resources/css/custom.scss +++ b/src/main/resources/css/custom.scss @@ -1,2 +1,3 @@ @use "dashboard"; @use "deregisterLinks"; +@use "linkGroupList"; diff --git a/src/main/resources/templates/propertyDetailsView.html b/src/main/resources/templates/propertyDetailsView.html index b283587b55..67bb53af6d 100644 --- a/src/main/resources/templates/propertyDetailsView.html +++ b/src/main/resources/templates/propertyDetailsView.html @@ -91,14 +91,14 @@

propertyDetails.landlordDetails.invitations.pendingInvitations.sentOn

-
    -
  • +