From 63363687fdcecb3b7b998d00aee5e1128bdc5d86 Mon Sep 17 00:00:00 2001 From: Bill Haigh Date: Fri, 29 May 2026 10:52:09 +0100 Subject: [PATCH 01/19] PDJB-268: Add joint landlord invitation expiry scheduled task and notification email --- ...andlordInvitationsTaskApplicationRunner.kt | 36 ++++ .../JointLandlordInvitationRepository.kt | 3 + .../emailModels/EmailTemplateModel.kt | 1 + .../JointLandlordInvitationExpiryEmail.kt | 25 +++ .../webapp/services/AbsoluteUrlProvider.kt | 3 + .../JointLandlordInvitationExpiryService.kt | 82 +++++++++ .../emails/JointLandlordInvitationExpiry.md | 21 +++ src/main/resources/emails/emailTemplates.json | 7 + .../emailModels/EmailTemplateModelsTests.kt | 9 + ...intLandlordInvitationExpiryServiceTests.kt | 173 ++++++++++++++++++ 10 files changed, 360 insertions(+) create mode 100644 src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt create mode 100644 src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/JointLandlordInvitationExpiryEmail.kt create mode 100644 src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt create mode 100644 src/main/resources/emails/JointLandlordInvitationExpiry.md create mode 100644 src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt new file mode 100644 index 0000000000..434523c832 --- /dev/null +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt @@ -0,0 +1,36 @@ +package uk.gov.communities.prsdb.webapp.application + +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.boot.SpringApplication +import org.springframework.context.ApplicationContext +import uk.gov.communities.prsdb.webapp.annotations.taskAnnotations.PrsdbScheduledTask +import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationExpiryService +import kotlin.system.exitProcess + +@PrsdbScheduledTask("expire-joint-landlord-invitations-scheduled-task") +class ExpireJointLandlordInvitationsTaskApplicationRunner( + private val context: ApplicationContext, + private val jointLandlordInvitationExpiryService: JointLandlordInvitationExpiryService, +) : ApplicationRunner { + override fun run(args: ApplicationArguments?) { + println("Executing expire joint landlord invitations scheduled task") + + // Separating into its own method to allow this to be tested without "exitProcess" being called + expireJointLandlordInvitationsTaskLogic() + + val code = + SpringApplication.exit(context, { 0 }).also { + println("Scheduled task executed. Application will exit now.") + } + exitProcess(code) + } + + private fun expireJointLandlordInvitationsTaskLogic() { + jointLandlordInvitationExpiryService.expirePendingInvitations() + } + + companion object { + const val EXPIRE_JOINT_LANDLORD_INVITATIONS_TASK_METHOD_NAME = "expireJointLandlordInvitationsTaskLogic" + } +} 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..b07ed73d2d 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 java.time.Instant import java.util.UUID interface JointLandlordInvitationRepository : JpaRepository { fun findByToken(token: UUID): JointLandlordInvitation? + + fun findAllByCreatedDateBefore(cutoff: Instant): List } diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/EmailTemplateModel.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/EmailTemplateModel.kt index 60c8db818a..852bfb9e1e 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/EmailTemplateModel.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/EmailTemplateModel.kt @@ -29,4 +29,5 @@ enum class EmailTemplate { LOCAL_COUNCIL_USER_INVITATION_INFORM_ADMIN_EMAIL, INCOMPLETE_PROPERTY_REMINDER_EMAIL, JOINT_LANDLORD_INVITATION_EMAIL, + JOINT_LANDLORD_INVITATION_EXPIRY_EMAIL, } diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/JointLandlordInvitationExpiryEmail.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/JointLandlordInvitationExpiryEmail.kt new file mode 100644 index 0000000000..fecc3fb56b --- /dev/null +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/JointLandlordInvitationExpiryEmail.kt @@ -0,0 +1,25 @@ +package uk.gov.communities.prsdb.webapp.models.viewModels.emailModels + +import java.net.URI + +data class JointLandlordInvitationExpiryEmail( + val recipientName: String, + val invitedEmail: String, + val propertyAddress: String, + val propertyRecordUri: URI, +) : EmailTemplateModel { + private val recipientNameKey = "recipientName" + private val invitedEmailKey = "invitedEmail" + private val propertyAddressKey = "propertyAddress" + private val propertyRecordUrlKey = "propertyRecordUrl" + + override val template = EmailTemplate.JOINT_LANDLORD_INVITATION_EXPIRY_EMAIL + + override fun toHashMap(): HashMap = + hashMapOf( + recipientNameKey to recipientName, + invitedEmailKey to invitedEmail, + propertyAddressKey to propertyAddress, + propertyRecordUrlKey to propertyRecordUri.toString(), + ) +} diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/AbsoluteUrlProvider.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/AbsoluteUrlProvider.kt index 4eb1b6b505..f0c50da639 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/AbsoluteUrlProvider.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/AbsoluteUrlProvider.kt @@ -50,6 +50,9 @@ class AbsoluteUrlProvider( .toUri() } + fun buildLandlordPropertyDetailsUri(propertyOwnershipId: Long): URI = + uriFromMethodCall(on(PropertyDetailsController::class.java).getPropertyDetails(propertyOwnershipId)) + private fun uriFromMethodCall(info: Any): URI { val methodCallUriComponents = MvcUriComponentsBuilder diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt new file mode 100644 index 0000000000..2844351ac1 --- /dev/null +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt @@ -0,0 +1,82 @@ +package uk.gov.communities.prsdb.webapp.services + +import org.springframework.context.annotation.Primary +import uk.gov.communities.prsdb.webapp.annotations.webAnnotations.PrsdbFlip +import uk.gov.communities.prsdb.webapp.annotations.webAnnotations.PrsdbWebService +import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORDS +import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS +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 +import uk.gov.communities.prsdb.webapp.database.repository.JointLandlordInvitationRepository +import uk.gov.communities.prsdb.webapp.exceptions.PersistentEmailSendException +import uk.gov.communities.prsdb.webapp.exceptions.TransientEmailSentException +import uk.gov.communities.prsdb.webapp.models.viewModels.emailModels.JointLandlordInvitationExpiryEmail +import java.time.Instant +import java.time.temporal.ChronoUnit + +@PrsdbFlip(name = JOINT_LANDLORDS, alterBean = "joint-landlord-invitation-expiry-flag-on") +interface JointLandlordInvitationExpiryService { + fun expirePendingInvitations() +} + +@Primary +@PrsdbWebService("joint-landlord-invitation-expiry-flag-off") +class JointLandlordInvitationExpiryServiceImplFlagOff : JointLandlordInvitationExpiryService { + override fun expirePendingInvitations() { + // No-op: the joint-landlords feature is disabled, so we do not expire invitations. + } +} + +@PrsdbWebService("joint-landlord-invitation-expiry-flag-on") +class JointLandlordInvitationExpiryServiceImplFlagOn( + private val invitationRepository: JointLandlordInvitationRepository, + private val expiryEmailNotificationService: EmailNotificationService, + private val absoluteUrlProvider: AbsoluteUrlProvider, +) : JointLandlordInvitationExpiryService { + override fun expirePendingInvitations() { + val cutoff = Instant.now().minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS.toLong(), ChronoUnit.HOURS) + val expiredInvitations = invitationRepository.findAllByCreatedDateBefore(cutoff) + + expiredInvitations.forEach { invitation -> + try { + sendExpiryEmailsForInvitation(invitation) + invitationRepository.delete(invitation) + } catch (ex: PersistentEmailSendException) { + printFailureMessage(ex, invitation) + } catch (ex: TransientEmailSentException) { + printFailureMessage(ex, invitation) + } + } + } + + private fun sendExpiryEmailsForInvitation(invitation: JointLandlordInvitation) { + val propertyOwnership = invitation.registeredOwnership + val propertyAddress = propertyOwnership.address.toMultiLineAddress() + val propertyRecordUri = absoluteUrlProvider.buildLandlordPropertyDetailsUri(propertyOwnership.id) + + getExpiryEmailRecipients(propertyOwnership).forEach { recipient -> + expiryEmailNotificationService.sendEmail( + recipient.email, + JointLandlordInvitationExpiryEmail( + recipientName = recipient.name, + invitedEmail = invitation.invitedEmail, + propertyAddress = propertyAddress, + propertyRecordUri = propertyRecordUri, + ), + ) + } + } + + // TODO PDJB-260: include accepted joint landlords once that data model exists. + private fun getExpiryEmailRecipients(propertyOwnership: PropertyOwnership): List = listOf(propertyOwnership.primaryLandlord) + + private fun printFailureMessage( + ex: Exception, + invitation: JointLandlordInvitation, + ) { + println("Failed to send expiry email for joint landlord invitation with id: ${invitation.id}") + println("Exception message: ${ex.message}") + println("Stack trace: ${ex.stackTraceToString()}") + } +} diff --git a/src/main/resources/emails/JointLandlordInvitationExpiry.md b/src/main/resources/emails/JointLandlordInvitationExpiry.md new file mode 100644 index 0000000000..3d047029c6 --- /dev/null +++ b/src/main/resources/emails/JointLandlordInvitationExpiry.md @@ -0,0 +1,21 @@ +Hello ((recipientName)), + +((invitedEmail)) was invited to be a joint landlord for: + +((propertyAddress)) + +We sent them an invitation, but did not hear back. As it has been over 28 days, the invitation has now expired. + +# What to do next + +All joint landlords must be registered on the property. + +If this person is a landlord, invite them again. If you can, contact them and ask them to accept the invitation. + +[View property record](((propertyRecordUrl))) + +Keep this email so you can reference it later. + +--- + +This is an automated email – do not reply. diff --git a/src/main/resources/emails/emailTemplates.json b/src/main/resources/emails/emailTemplates.json index b0a071dc92..037301a29e 100644 --- a/src/main/resources/emails/emailTemplates.json +++ b/src/main/resources/emails/emailTemplates.json @@ -155,5 +155,12 @@ "enumName": "JOINT_LANDLORD_INVITATION_EMAIL", "subject": "You've been invited to join a rental property registration", "bodyLocation": "/emails/JointLandlordInvitation.md" + }, + { + "test_id": "00000000-0000-0000-0000-000000000268", + "prod_id": "00000000-0000-0000-0000-000000000268", + "enumName": "JOINT_LANDLORD_INVITATION_EXPIRY_EMAIL", + "subject": "Your invitation to a joint landlord has expired", + "bodyLocation": "/emails/JointLandlordInvitationExpiry.md" } ] diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/EmailTemplateModelsTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/EmailTemplateModelsTests.kt index 26c40cf1f3..3f930db9a8 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/EmailTemplateModelsTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/EmailTemplateModelsTests.kt @@ -172,6 +172,15 @@ class EmailTemplateModelsTests { JointLandlordInvitationEmail("John Smith", "1 Fake Street, London", URI("invitationUrl")), "/emails/JointLandlordInvitation.md", ), + EmailTemplateTestData( + JointLandlordInvitationExpiryEmail( + recipientName = "Lois", + invitedEmail = "invited@example.com", + propertyAddress = "1 Fake Street\nLondon\nSW1A 1AA", + propertyRecordUri = URI("propertyRecordUrl"), + ), + "/emails/JointLandlordInvitationExpiry.md", + ), ) } diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt new file mode 100644 index 0000000000..5497d5ae2b --- /dev/null +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt @@ -0,0 +1,173 @@ +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.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS +import uk.gov.communities.prsdb.webapp.database.entity.JointLandlordInvitation +import uk.gov.communities.prsdb.webapp.database.repository.JointLandlordInvitationRepository +import uk.gov.communities.prsdb.webapp.exceptions.PersistentEmailSendException +import uk.gov.communities.prsdb.webapp.models.viewModels.emailModels.JointLandlordInvitationExpiryEmail +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 JointLandlordInvitationExpiryServiceTests { + private lateinit var mockJointLandlordInvitationRepository: JointLandlordInvitationRepository + private lateinit var mockExpiryEmailNotificationService: EmailNotificationService + private lateinit var mockAbsoluteUrlProvider: AbsoluteUrlProvider + private lateinit var expiryService: JointLandlordInvitationExpiryServiceImplFlagOn + + @BeforeEach + fun setup() { + mockJointLandlordInvitationRepository = mock() + mockExpiryEmailNotificationService = mock() + mockAbsoluteUrlProvider = mock() + expiryService = + JointLandlordInvitationExpiryServiceImplFlagOn( + mockJointLandlordInvitationRepository, + mockExpiryEmailNotificationService, + mockAbsoluteUrlProvider, + ) + } + + @Test + fun `expirePendingInvitations queries the repository with a cutoff of 28 days ago`() { + whenever(mockJointLandlordInvitationRepository.findAllByCreatedDateBefore(any())) + .thenReturn(emptyList()) + + val beforeCall = Instant.now() + expiryService.expirePendingInvitations() + val afterCall = Instant.now() + + val cutoffCaptor = argumentCaptor() + verify(mockJointLandlordInvitationRepository).findAllByCreatedDateBefore(cutoffCaptor.capture()) + + val expectedCutoffLowerBound = + beforeCall.minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS.toLong(), ChronoUnit.HOURS) + val expectedCutoffUpperBound = + afterCall.minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS.toLong(), ChronoUnit.HOURS) + val actualCutoff = cutoffCaptor.firstValue + + assert(actualCutoff in expectedCutoffLowerBound..expectedCutoffUpperBound) { + "Cutoff $actualCutoff was outside expected window [$expectedCutoffLowerBound, $expectedCutoffUpperBound]" + } + } + + @Test + fun `expirePendingInvitations sends expiry email to the primary landlord for each expired invitation`() { + val primaryLandlord = MockLandlordData.createLandlord(name = "Lois", email = "lois@example.com") + val address = MockLandlordData.createAddress(singleLineAddress = "Flat 1, 11 Elm Drive, London, NW8 2DK") + val propertyOwnership = MockLandlordData.createPropertyOwnership(primaryLandlord = primaryLandlord, address = address) + val invitation = + MockJointLandlordData.createJointLandlordInvitation( + email = "very-real-email@example.com", + propertyOwnership = propertyOwnership, + ) + val propertyRecordUri = URI("https://example.com/landlord/property/1") + + whenever(mockJointLandlordInvitationRepository.findAllByCreatedDateBefore(any())) + .thenReturn(listOf(invitation)) + whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) + .thenReturn(propertyRecordUri) + + expiryService.expirePendingInvitations() + + val emailModelCaptor = argumentCaptor() + verify(mockExpiryEmailNotificationService).sendEmail(eq("lois@example.com"), emailModelCaptor.capture()) + + val sentEmail = emailModelCaptor.firstValue + assertEquals("Lois", sentEmail.recipientName) + assertEquals("very-real-email@example.com", sentEmail.invitedEmail) + assertEquals("Flat 1\n11 Elm Drive\nLondon\nNW8 2DK", sentEmail.propertyAddress) + assertEquals(propertyRecordUri, sentEmail.propertyRecordUri) + } + + @Test + fun `expirePendingInvitations sends one email per expired invitation`() { + val invitations = + listOf( + MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "first@example.com"), + MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "second@example.com"), + MockJointLandlordData.createJointLandlordInvitation(id = 3, email = "third@example.com"), + ) + + whenever(mockJointLandlordInvitationRepository.findAllByCreatedDateBefore(any())) + .thenReturn(invitations) + whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) + .thenReturn(URI("https://example.com/landlord/property/1")) + + expiryService.expirePendingInvitations() + + verify(mockExpiryEmailNotificationService, times(3)).sendEmail(any(), any()) + } + + @Test + fun `expirePendingInvitations deletes each invitation after sending the email`() { + val invitations = + listOf( + MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "first@example.com"), + MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "second@example.com"), + ) + + whenever(mockJointLandlordInvitationRepository.findAllByCreatedDateBefore(any())) + .thenReturn(invitations) + whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) + .thenReturn(URI("https://example.com/landlord/property/1")) + + expiryService.expirePendingInvitations() + + invitations.forEach { invitation -> + verify(mockJointLandlordInvitationRepository).delete(invitation) + } + } + + @Test + fun `expirePendingInvitations does nothing when there are no expired invitations`() { + whenever(mockJointLandlordInvitationRepository.findAllByCreatedDateBefore(any())) + .thenReturn(emptyList()) + + expiryService.expirePendingInvitations() + + verify(mockExpiryEmailNotificationService, never()).sendEmail(any(), any()) + verify(mockJointLandlordInvitationRepository, never()).delete(any()) + } + + @Test + fun `expirePendingInvitations continues processing and does not delete the failed invitation when an email send fails`() { + val failingInvitation = MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "fail@example.com") + val succeedingInvitation = MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "ok@example.com") + + whenever(mockJointLandlordInvitationRepository.findAllByCreatedDateBefore(any())) + .thenReturn(listOf(failingInvitation, succeedingInvitation)) + whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) + .thenReturn(URI("https://example.com/landlord/property/1")) + whenever(mockExpiryEmailNotificationService.sendEmail(any(), any())) + .thenThrow(PersistentEmailSendException("boom")) + .thenAnswer { /* succeed on the second call */ } + + expiryService.expirePendingInvitations() + + verify(mockJointLandlordInvitationRepository, never()).delete(failingInvitation) + verify(mockJointLandlordInvitationRepository).delete(succeedingInvitation) + } + + @Test + fun `flag-off implementation does nothing`() { + val flagOff = JointLandlordInvitationExpiryServiceImplFlagOff() + + flagOff.expirePendingInvitations() + + // No dependencies, no side effects - nothing to verify beyond not throwing. + } +} From c654ca199ef09d4314a9e679d609df036774ee05 Mon Sep 17 00:00:00 2001 From: Bill Haigh Date: Fri, 29 May 2026 13:37:12 +0100 Subject: [PATCH 02/19] PDJB-268: Set Notify template IDs for joint landlord invitation expiry email --- src/main/resources/emails/emailTemplates.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/emails/emailTemplates.json b/src/main/resources/emails/emailTemplates.json index 037301a29e..b3f1e7eb86 100644 --- a/src/main/resources/emails/emailTemplates.json +++ b/src/main/resources/emails/emailTemplates.json @@ -157,8 +157,8 @@ "bodyLocation": "/emails/JointLandlordInvitation.md" }, { - "test_id": "00000000-0000-0000-0000-000000000268", - "prod_id": "00000000-0000-0000-0000-000000000268", + "test_id": "829697a4-7432-4683-bb43-29f73da609d4", + "prod_id": "0bb9ffed-9319-4369-b9e1-2c7d54d11326", "enumName": "JOINT_LANDLORD_INVITATION_EXPIRY_EMAIL", "subject": "Your invitation to a joint landlord has expired", "bodyLocation": "/emails/JointLandlordInvitationExpiry.md" From 20f911cfa86269532ff6d2542f48a14993575fd5 Mon Sep 17 00:00:00 2001 From: Bill Haigh Date: Fri, 29 May 2026 13:56:10 +0100 Subject: [PATCH 03/19] PDJB-268: trailing whitespace --- src/main/resources/emails/JointLandlordInvitationExpiry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/emails/JointLandlordInvitationExpiry.md b/src/main/resources/emails/JointLandlordInvitationExpiry.md index 3d047029c6..85583ce527 100644 --- a/src/main/resources/emails/JointLandlordInvitationExpiry.md +++ b/src/main/resources/emails/JointLandlordInvitationExpiry.md @@ -18,4 +18,4 @@ Keep this email so you can reference it later. --- -This is an automated email – do not reply. +This is an automated email – do not reply. \ No newline at end of file From 9e90963dbce3de3f3965a85b4656c098a25f169d Mon Sep 17 00:00:00 2001 From: Bill Haigh Date: Fri, 29 May 2026 14:05:18 +0100 Subject: [PATCH 04/19] PDJB-268: Address review - use task stereotype and tidy expiry email - Switch JointLandlordInvitationExpiryService impls to @PrsdbTaskService so they load in task-runner mode - Add the two flag-on/flag-off beans to PrsdbTaskApplicationTests expected-bean set - Add ExpireJointLandlordInvitationsTaskApplicationRunnerTests covering the runner method - Derive expiry days from JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS and pass via a Notify placeholder instead of hardcoding 28 days - Reword expiry email subject to reflect the inviting landlord is the recipient --- .../JointLandlordInvitationExpiryEmail.kt | 3 ++ .../JointLandlordInvitationExpiryService.kt | 12 ++++-- .../emails/JointLandlordInvitationExpiry.md | 2 +- src/main/resources/emails/emailTemplates.json | 2 +- .../applications/PrsdbTaskApplicationTests.kt | 2 + .../emailModels/EmailTemplateModelsTests.kt | 1 + ...rdInvitationsTaskApplicationRunnerTests.kt | 39 +++++++++++++++++++ ...intLandlordInvitationExpiryServiceTests.kt | 1 + 8 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/JointLandlordInvitationExpiryEmail.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/JointLandlordInvitationExpiryEmail.kt index fecc3fb56b..8355de3bd5 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/JointLandlordInvitationExpiryEmail.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/JointLandlordInvitationExpiryEmail.kt @@ -7,11 +7,13 @@ data class JointLandlordInvitationExpiryEmail( val invitedEmail: String, val propertyAddress: String, val propertyRecordUri: URI, + val expiryDays: Int, ) : EmailTemplateModel { private val recipientNameKey = "recipientName" private val invitedEmailKey = "invitedEmail" private val propertyAddressKey = "propertyAddress" private val propertyRecordUrlKey = "propertyRecordUrl" + private val expiryDaysKey = "expiryDays" override val template = EmailTemplate.JOINT_LANDLORD_INVITATION_EXPIRY_EMAIL @@ -21,5 +23,6 @@ data class JointLandlordInvitationExpiryEmail( invitedEmailKey to invitedEmail, propertyAddressKey to propertyAddress, propertyRecordUrlKey to propertyRecordUri.toString(), + expiryDaysKey to expiryDays.toString(), ) } diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt index 2844351ac1..c3927f252e 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt @@ -1,8 +1,8 @@ package uk.gov.communities.prsdb.webapp.services import org.springframework.context.annotation.Primary +import uk.gov.communities.prsdb.webapp.annotations.taskAnnotations.PrsdbTaskService import uk.gov.communities.prsdb.webapp.annotations.webAnnotations.PrsdbFlip -import uk.gov.communities.prsdb.webapp.annotations.webAnnotations.PrsdbWebService import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORDS import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS import uk.gov.communities.prsdb.webapp.database.entity.JointLandlordInvitation @@ -21,14 +21,14 @@ interface JointLandlordInvitationExpiryService { } @Primary -@PrsdbWebService("joint-landlord-invitation-expiry-flag-off") +@PrsdbTaskService("joint-landlord-invitation-expiry-flag-off") class JointLandlordInvitationExpiryServiceImplFlagOff : JointLandlordInvitationExpiryService { override fun expirePendingInvitations() { // No-op: the joint-landlords feature is disabled, so we do not expire invitations. } } -@PrsdbWebService("joint-landlord-invitation-expiry-flag-on") +@PrsdbTaskService("joint-landlord-invitation-expiry-flag-on") class JointLandlordInvitationExpiryServiceImplFlagOn( private val invitationRepository: JointLandlordInvitationRepository, private val expiryEmailNotificationService: EmailNotificationService, @@ -54,6 +54,7 @@ class JointLandlordInvitationExpiryServiceImplFlagOn( val propertyOwnership = invitation.registeredOwnership val propertyAddress = propertyOwnership.address.toMultiLineAddress() val propertyRecordUri = absoluteUrlProvider.buildLandlordPropertyDetailsUri(propertyOwnership.id) + val expiryDays = JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS / HOURS_PER_DAY getExpiryEmailRecipients(propertyOwnership).forEach { recipient -> expiryEmailNotificationService.sendEmail( @@ -63,6 +64,7 @@ class JointLandlordInvitationExpiryServiceImplFlagOn( invitedEmail = invitation.invitedEmail, propertyAddress = propertyAddress, propertyRecordUri = propertyRecordUri, + expiryDays = expiryDays, ), ) } @@ -79,4 +81,8 @@ class JointLandlordInvitationExpiryServiceImplFlagOn( println("Exception message: ${ex.message}") println("Stack trace: ${ex.stackTraceToString()}") } + + companion object { + private const val HOURS_PER_DAY = 24 + } } diff --git a/src/main/resources/emails/JointLandlordInvitationExpiry.md b/src/main/resources/emails/JointLandlordInvitationExpiry.md index 85583ce527..f5572f7ac1 100644 --- a/src/main/resources/emails/JointLandlordInvitationExpiry.md +++ b/src/main/resources/emails/JointLandlordInvitationExpiry.md @@ -4,7 +4,7 @@ Hello ((recipientName)), ((propertyAddress)) -We sent them an invitation, but did not hear back. As it has been over 28 days, the invitation has now expired. +We sent them an invitation, but did not hear back. As it has been over ((expiryDays)) days, the invitation has now expired. # What to do next diff --git a/src/main/resources/emails/emailTemplates.json b/src/main/resources/emails/emailTemplates.json index b3f1e7eb86..c18459f79c 100644 --- a/src/main/resources/emails/emailTemplates.json +++ b/src/main/resources/emails/emailTemplates.json @@ -160,7 +160,7 @@ "test_id": "829697a4-7432-4683-bb43-29f73da609d4", "prod_id": "0bb9ffed-9319-4369-b9e1-2c7d54d11326", "enumName": "JOINT_LANDLORD_INVITATION_EXPIRY_EMAIL", - "subject": "Your invitation to a joint landlord has expired", + "subject": "A joint landlord invitation you sent has expired", "bodyLocation": "/emails/JointLandlordInvitationExpiry.md" } ] diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/applications/PrsdbTaskApplicationTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/applications/PrsdbTaskApplicationTests.kt index 27a81ec9c6..8054ecec1b 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/applications/PrsdbTaskApplicationTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/applications/PrsdbTaskApplicationTests.kt @@ -79,6 +79,8 @@ class PrsdbTaskApplicationTests { PropertyOwnershipSearchRepositoryImpl::class.simpleBeanName, LandlordSearchRepositoryImpl::class.simpleBeanName, IncompletePropertiesService::class.simpleBeanName, + "joint-landlord-invitation-expiry-flag-off", + "joint-landlord-invitation-expiry-flag-on", ).map { it.lowercase() }.toSet() val beanNames = ApplicationTestHelper.getAvailableBeanNames(context!!) diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/EmailTemplateModelsTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/EmailTemplateModelsTests.kt index 3f930db9a8..e489ecfeb6 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/EmailTemplateModelsTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/EmailTemplateModelsTests.kt @@ -178,6 +178,7 @@ class EmailTemplateModelsTests { invitedEmail = "invited@example.com", propertyAddress = "1 Fake Street\nLondon\nSW1A 1AA", propertyRecordUri = URI("propertyRecordUrl"), + expiryDays = 28, ), "/emails/JointLandlordInvitationExpiry.md", ), diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt new file mode 100644 index 0000000000..2512c67ae4 --- /dev/null +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt @@ -0,0 +1,39 @@ +package uk.gov.communities.prsdb.webapp.scheduledTaskRunners + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.verify +import org.springframework.context.ApplicationContext +import uk.gov.communities.prsdb.webapp.application.ExpireJointLandlordInvitationsTaskApplicationRunner +import uk.gov.communities.prsdb.webapp.application.ExpireJointLandlordInvitationsTaskApplicationRunner.Companion.EXPIRE_JOINT_LANDLORD_INVITATIONS_TASK_METHOD_NAME +import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationExpiryService + +@ExtendWith(MockitoExtension::class) +class ExpireJointLandlordInvitationsTaskApplicationRunnerTests { + @Mock + private lateinit var context: ApplicationContext + + @Mock + private lateinit var jointLandlordInvitationExpiryService: JointLandlordInvitationExpiryService + + @InjectMocks + private lateinit var runner: ExpireJointLandlordInvitationsTaskApplicationRunner + + @Test + fun `expireJointLandlordInvitationsTaskLogic calls service to expire pending invitations`() { + // Arrange + val method = + ExpireJointLandlordInvitationsTaskApplicationRunner::class.java + .getDeclaredMethod(EXPIRE_JOINT_LANDLORD_INVITATIONS_TASK_METHOD_NAME) + method.isAccessible = true + + // Act + method.invoke(runner) + + // Assert + verify(jointLandlordInvitationExpiryService).expirePendingInvitations() + } +} diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt index 5497d5ae2b..5842b7b5b2 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt @@ -91,6 +91,7 @@ class JointLandlordInvitationExpiryServiceTests { assertEquals("very-real-email@example.com", sentEmail.invitedEmail) assertEquals("Flat 1\n11 Elm Drive\nLondon\nNW8 2DK", sentEmail.propertyAddress) assertEquals(propertyRecordUri, sentEmail.propertyRecordUri) + assertEquals(28, sentEmail.expiryDays) } @Test From 03bc15d552d4cd5f6ab8c34963ee5039161231ef Mon Sep 17 00:00:00 2001 From: Bill Haigh Date: Fri, 29 May 2026 14:42:45 +0100 Subject: [PATCH 05/19] PDJB-268: Revert expiry email subject to match Notify template --- src/main/resources/emails/emailTemplates.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/emails/emailTemplates.json b/src/main/resources/emails/emailTemplates.json index c18459f79c..b3f1e7eb86 100644 --- a/src/main/resources/emails/emailTemplates.json +++ b/src/main/resources/emails/emailTemplates.json @@ -160,7 +160,7 @@ "test_id": "829697a4-7432-4683-bb43-29f73da609d4", "prod_id": "0bb9ffed-9319-4369-b9e1-2c7d54d11326", "enumName": "JOINT_LANDLORD_INVITATION_EXPIRY_EMAIL", - "subject": "A joint landlord invitation you sent has expired", + "subject": "Your invitation to a joint landlord has expired", "bodyLocation": "/emails/JointLandlordInvitationExpiry.md" } ] From a39fa17372bbcbdc9460277e68d4d2e4ab3f7a90 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:09:47 +0100 Subject: [PATCH 06/19] PDJB-268: Use an expiry flag for expired JL invitations --- .../entity/JointLandlordInvitation.kt | 8 ++++ .../JointLandlordInvitationRepository.kt | 2 +- .../JointLandlordInvitationExpiryService.kt | 5 ++- ...ired_flag_to_joint_landlord_invitation.sql | 1 + ...intLandlordInvitationExpiryServiceTests.kt | 43 +++++++++++++------ 5 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 src/main/resources/db/migrations/V1_28_0__add_expired_flag_to_joint_landlord_invitation.sql 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..ffe3c0ec2f 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 @@ -33,6 +33,14 @@ class JointLandlordInvitation( lateinit var invitingLandlord: Landlord private set + @Column(nullable = false) + var expired: Boolean = false + private set + + fun markAsExpired() { + expired = true + } + 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 b07ed73d2d..4e51390ce0 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 @@ -8,5 +8,5 @@ import java.util.UUID interface JointLandlordInvitationRepository : JpaRepository { fun findByToken(token: UUID): JointLandlordInvitation? - fun findAllByCreatedDateBefore(cutoff: Instant): List + fun findAllByExpiredFalseAndCreatedDateBefore(cutoff: Instant): List } diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt index c3927f252e..8360a6c8e2 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt @@ -36,12 +36,13 @@ class JointLandlordInvitationExpiryServiceImplFlagOn( ) : JointLandlordInvitationExpiryService { override fun expirePendingInvitations() { val cutoff = Instant.now().minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS.toLong(), ChronoUnit.HOURS) - val expiredInvitations = invitationRepository.findAllByCreatedDateBefore(cutoff) + val expiredInvitations = invitationRepository.findAllByExpiredFalseAndCreatedDateBefore(cutoff) expiredInvitations.forEach { invitation -> try { sendExpiryEmailsForInvitation(invitation) - invitationRepository.delete(invitation) + invitation.markAsExpired() + invitationRepository.save(invitation) } catch (ex: PersistentEmailSendException) { printFailureMessage(ex, invitation) } catch (ex: TransientEmailSentException) { diff --git a/src/main/resources/db/migrations/V1_28_0__add_expired_flag_to_joint_landlord_invitation.sql b/src/main/resources/db/migrations/V1_28_0__add_expired_flag_to_joint_landlord_invitation.sql new file mode 100644 index 0000000000..4e0cc73ead --- /dev/null +++ b/src/main/resources/db/migrations/V1_28_0__add_expired_flag_to_joint_landlord_invitation.sql @@ -0,0 +1 @@ +ALTER TABLE joint_landlord_invitation ADD COLUMN expired BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt index 5842b7b5b2..515f5d23f6 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt @@ -43,7 +43,7 @@ class JointLandlordInvitationExpiryServiceTests { @Test fun `expirePendingInvitations queries the repository with a cutoff of 28 days ago`() { - whenever(mockJointLandlordInvitationRepository.findAllByCreatedDateBefore(any())) + whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) .thenReturn(emptyList()) val beforeCall = Instant.now() @@ -51,7 +51,7 @@ class JointLandlordInvitationExpiryServiceTests { val afterCall = Instant.now() val cutoffCaptor = argumentCaptor() - verify(mockJointLandlordInvitationRepository).findAllByCreatedDateBefore(cutoffCaptor.capture()) + verify(mockJointLandlordInvitationRepository).findAllByExpiredFalseAndCreatedDateBefore(cutoffCaptor.capture()) val expectedCutoffLowerBound = beforeCall.minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS.toLong(), ChronoUnit.HOURS) @@ -76,7 +76,7 @@ class JointLandlordInvitationExpiryServiceTests { ) val propertyRecordUri = URI("https://example.com/landlord/property/1") - whenever(mockJointLandlordInvitationRepository.findAllByCreatedDateBefore(any())) + whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) .thenReturn(listOf(invitation)) whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) .thenReturn(propertyRecordUri) @@ -103,7 +103,7 @@ class JointLandlordInvitationExpiryServiceTests { MockJointLandlordData.createJointLandlordInvitation(id = 3, email = "third@example.com"), ) - whenever(mockJointLandlordInvitationRepository.findAllByCreatedDateBefore(any())) + whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) .thenReturn(invitations) whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) .thenReturn(URI("https://example.com/landlord/property/1")) @@ -114,14 +114,30 @@ class JointLandlordInvitationExpiryServiceTests { } @Test - fun `expirePendingInvitations deletes each invitation after sending the email`() { + fun `expirePendingInvitations marks a non-expired invitation as expired`() { + val invitation = MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "test@example.com") + assert(!invitation.expired) { "Expected invitation to start as non-expired" } + + whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) + .thenReturn(listOf(invitation)) + whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) + .thenReturn(URI("https://example.com/landlord/property/1")) + + expiryService.expirePendingInvitations() + + assert(invitation.expired) { "Expected invitation to be marked as expired" } + verify(mockJointLandlordInvitationRepository).save(invitation) + } + + @Test + fun `expirePendingInvitations marks each invitation as expired and saves it after sending the email`() { val invitations = listOf( MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "first@example.com"), MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "second@example.com"), ) - whenever(mockJointLandlordInvitationRepository.findAllByCreatedDateBefore(any())) + whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) .thenReturn(invitations) whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) .thenReturn(URI("https://example.com/landlord/property/1")) @@ -129,19 +145,20 @@ class JointLandlordInvitationExpiryServiceTests { expiryService.expirePendingInvitations() invitations.forEach { invitation -> - verify(mockJointLandlordInvitationRepository).delete(invitation) + assert(invitation.expired) { "Expected invitation ${invitation.id} to be marked as expired" } + verify(mockJointLandlordInvitationRepository).save(invitation) } } @Test fun `expirePendingInvitations does nothing when there are no expired invitations`() { - whenever(mockJointLandlordInvitationRepository.findAllByCreatedDateBefore(any())) + whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) .thenReturn(emptyList()) expiryService.expirePendingInvitations() verify(mockExpiryEmailNotificationService, never()).sendEmail(any(), any()) - verify(mockJointLandlordInvitationRepository, never()).delete(any()) + verify(mockJointLandlordInvitationRepository, never()).save(any()) } @Test @@ -149,7 +166,7 @@ class JointLandlordInvitationExpiryServiceTests { val failingInvitation = MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "fail@example.com") val succeedingInvitation = MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "ok@example.com") - whenever(mockJointLandlordInvitationRepository.findAllByCreatedDateBefore(any())) + whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) .thenReturn(listOf(failingInvitation, succeedingInvitation)) whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) .thenReturn(URI("https://example.com/landlord/property/1")) @@ -159,8 +176,10 @@ class JointLandlordInvitationExpiryServiceTests { expiryService.expirePendingInvitations() - verify(mockJointLandlordInvitationRepository, never()).delete(failingInvitation) - verify(mockJointLandlordInvitationRepository).delete(succeedingInvitation) + verify(mockJointLandlordInvitationRepository, never()).save(failingInvitation) + assert(!failingInvitation.expired) { "Expected failing invitation to not be marked as expired" } + verify(mockJointLandlordInvitationRepository).save(succeedingInvitation) + assert(succeedingInvitation.expired) { "Expected succeeding invitation to be marked as expired" } } @Test From b98fc8c933b54693a88433a70626d980205877ce Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:11:39 +0100 Subject: [PATCH 07/19] PDJB-268: Note JL inv expiry in days this is how we treat it in code / in wording --- .../webapp/constants/InvitationLifetimeConstants.kt | 2 +- .../services/JointLandlordInvitationExpiryService.kt | 11 +++-------- .../JointLandlordInvitationExpiryServiceTests.kt | 6 +++--- 3 files changed, 7 insertions(+), 12 deletions(-) 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/services/JointLandlordInvitationExpiryService.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt index 8360a6c8e2..52f7969bb5 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt @@ -4,7 +4,7 @@ import org.springframework.context.annotation.Primary import uk.gov.communities.prsdb.webapp.annotations.taskAnnotations.PrsdbTaskService import uk.gov.communities.prsdb.webapp.annotations.webAnnotations.PrsdbFlip import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORDS -import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS +import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS 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 @@ -35,7 +35,7 @@ class JointLandlordInvitationExpiryServiceImplFlagOn( private val absoluteUrlProvider: AbsoluteUrlProvider, ) : JointLandlordInvitationExpiryService { override fun expirePendingInvitations() { - val cutoff = Instant.now().minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS.toLong(), ChronoUnit.HOURS) + val cutoff = Instant.now().minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS) val expiredInvitations = invitationRepository.findAllByExpiredFalseAndCreatedDateBefore(cutoff) expiredInvitations.forEach { invitation -> @@ -55,7 +55,6 @@ class JointLandlordInvitationExpiryServiceImplFlagOn( val propertyOwnership = invitation.registeredOwnership val propertyAddress = propertyOwnership.address.toMultiLineAddress() val propertyRecordUri = absoluteUrlProvider.buildLandlordPropertyDetailsUri(propertyOwnership.id) - val expiryDays = JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS / HOURS_PER_DAY getExpiryEmailRecipients(propertyOwnership).forEach { recipient -> expiryEmailNotificationService.sendEmail( @@ -65,7 +64,7 @@ class JointLandlordInvitationExpiryServiceImplFlagOn( invitedEmail = invitation.invitedEmail, propertyAddress = propertyAddress, propertyRecordUri = propertyRecordUri, - expiryDays = expiryDays, + expiryDays = JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS, ), ) } @@ -82,8 +81,4 @@ class JointLandlordInvitationExpiryServiceImplFlagOn( println("Exception message: ${ex.message}") println("Stack trace: ${ex.stackTraceToString()}") } - - companion object { - private const val HOURS_PER_DAY = 24 - } } diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt index 515f5d23f6..bd9e59c610 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt @@ -11,7 +11,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.whenever -import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS +import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS import uk.gov.communities.prsdb.webapp.database.entity.JointLandlordInvitation import uk.gov.communities.prsdb.webapp.database.repository.JointLandlordInvitationRepository import uk.gov.communities.prsdb.webapp.exceptions.PersistentEmailSendException @@ -54,9 +54,9 @@ class JointLandlordInvitationExpiryServiceTests { verify(mockJointLandlordInvitationRepository).findAllByExpiredFalseAndCreatedDateBefore(cutoffCaptor.capture()) val expectedCutoffLowerBound = - beforeCall.minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS.toLong(), ChronoUnit.HOURS) + beforeCall.minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS) val expectedCutoffUpperBound = - afterCall.minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS.toLong(), ChronoUnit.HOURS) + afterCall.minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS) val actualCutoff = cutoffCaptor.firstValue assert(actualCutoff in expectedCutoffLowerBound..expectedCutoffUpperBound) { From 65e8c77224596f899656ecd072c5079d60ccb7a4 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:23:07 +0100 Subject: [PATCH 08/19] PDJB-268: Remove unneeded orchestration from email regular jobs much more suitable to test the underlying logic than that a single method call makes another method call --- ...completePropertiesTaskApplicationRunner.kt | 13 +------ ...andlordInvitationsTaskApplicationRunner.kt | 11 +----- ...etePropertiesTaskApplicationRunnerTests.kt | 39 ------------------- ...rdInvitationsTaskApplicationRunnerTests.kt | 39 ------------------- 4 files changed, 3 insertions(+), 99 deletions(-) delete mode 100644 src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/DeleteIncompletePropertiesTaskApplicationRunnerTests.kt delete mode 100644 src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/DeleteIncompletePropertiesTaskApplicationRunner.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/DeleteIncompletePropertiesTaskApplicationRunner.kt index f2e17a85c0..3e8f5803c7 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/DeleteIncompletePropertiesTaskApplicationRunner.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/DeleteIncompletePropertiesTaskApplicationRunner.kt @@ -16,8 +16,8 @@ class DeleteIncompletePropertiesTaskApplicationRunner( override fun run(args: ApplicationArguments) { println("Executing delete incomplete properties scheduled task") - // Separating into its own method to allow this to be tested without "exitProcess" being called - deleteIncompletePropertiesTaskLogic() + val numberOfRecordsDeleted = incompletePropertiesService.deleteIncompletePropertiesOlderThan28Days() + println("Deleted $numberOfRecordsDeleted incomplete properties.") val code = SpringApplication.exit(context, { 0 }).also { @@ -25,13 +25,4 @@ class DeleteIncompletePropertiesTaskApplicationRunner( } exitProcess(code) } - - private fun deleteIncompletePropertiesTaskLogic() { - val numberOfRecordsDeleted = incompletePropertiesService.deleteIncompletePropertiesOlderThan28Days() - println("Deleted $numberOfRecordsDeleted incomplete properties.") - } - - companion object { - const val DELETE_INCOMPLETE_PROPERTIES_TASK_METHOD_NAME = "deleteIncompletePropertiesTaskLogic" - } } diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt index 434523c832..fbeae7a75f 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt @@ -16,8 +16,7 @@ class ExpireJointLandlordInvitationsTaskApplicationRunner( override fun run(args: ApplicationArguments?) { println("Executing expire joint landlord invitations scheduled task") - // Separating into its own method to allow this to be tested without "exitProcess" being called - expireJointLandlordInvitationsTaskLogic() + jointLandlordInvitationExpiryService.expirePendingInvitations() val code = SpringApplication.exit(context, { 0 }).also { @@ -25,12 +24,4 @@ class ExpireJointLandlordInvitationsTaskApplicationRunner( } exitProcess(code) } - - private fun expireJointLandlordInvitationsTaskLogic() { - jointLandlordInvitationExpiryService.expirePendingInvitations() - } - - companion object { - const val EXPIRE_JOINT_LANDLORD_INVITATIONS_TASK_METHOD_NAME = "expireJointLandlordInvitationsTaskLogic" - } } diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/DeleteIncompletePropertiesTaskApplicationRunnerTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/DeleteIncompletePropertiesTaskApplicationRunnerTests.kt deleted file mode 100644 index cdb62ec88a..0000000000 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/DeleteIncompletePropertiesTaskApplicationRunnerTests.kt +++ /dev/null @@ -1,39 +0,0 @@ -package uk.gov.communities.prsdb.webapp.scheduledTaskRunners - -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.InjectMocks -import org.mockito.Mock -import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.verify -import org.springframework.context.ApplicationContext -import uk.gov.communities.prsdb.webapp.application.DeleteIncompletePropertiesTaskApplicationRunner -import uk.gov.communities.prsdb.webapp.application.DeleteIncompletePropertiesTaskApplicationRunner.Companion.DELETE_INCOMPLETE_PROPERTIES_TASK_METHOD_NAME -import uk.gov.communities.prsdb.webapp.services.IncompletePropertiesService - -@ExtendWith(MockitoExtension::class) -class DeleteIncompletePropertiesTaskApplicationRunnerTests { - @Mock - private lateinit var context: ApplicationContext - - @Mock - private lateinit var incompletePropertiesService: IncompletePropertiesService - - @InjectMocks - private lateinit var runner: DeleteIncompletePropertiesTaskApplicationRunner - - @Test - fun `deleteIncompletePropertiesTaskLogic calls service to delete incomplete properties older than 28 days`() { - // Arrange - val method = - DeleteIncompletePropertiesTaskApplicationRunner::class.java - .getDeclaredMethod(DELETE_INCOMPLETE_PROPERTIES_TASK_METHOD_NAME) - method.isAccessible = true - - // Act - method.invoke(runner) - - // Assert - verify(incompletePropertiesService).deleteIncompletePropertiesOlderThan28Days() - } -} diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt deleted file mode 100644 index 2512c67ae4..0000000000 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt +++ /dev/null @@ -1,39 +0,0 @@ -package uk.gov.communities.prsdb.webapp.scheduledTaskRunners - -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.InjectMocks -import org.mockito.Mock -import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.verify -import org.springframework.context.ApplicationContext -import uk.gov.communities.prsdb.webapp.application.ExpireJointLandlordInvitationsTaskApplicationRunner -import uk.gov.communities.prsdb.webapp.application.ExpireJointLandlordInvitationsTaskApplicationRunner.Companion.EXPIRE_JOINT_LANDLORD_INVITATIONS_TASK_METHOD_NAME -import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationExpiryService - -@ExtendWith(MockitoExtension::class) -class ExpireJointLandlordInvitationsTaskApplicationRunnerTests { - @Mock - private lateinit var context: ApplicationContext - - @Mock - private lateinit var jointLandlordInvitationExpiryService: JointLandlordInvitationExpiryService - - @InjectMocks - private lateinit var runner: ExpireJointLandlordInvitationsTaskApplicationRunner - - @Test - fun `expireJointLandlordInvitationsTaskLogic calls service to expire pending invitations`() { - // Arrange - val method = - ExpireJointLandlordInvitationsTaskApplicationRunner::class.java - .getDeclaredMethod(EXPIRE_JOINT_LANDLORD_INVITATIONS_TASK_METHOD_NAME) - method.isAccessible = true - - // Act - method.invoke(runner) - - // Assert - verify(jointLandlordInvitationExpiryService).expirePendingInvitations() - } -} From 94a6d400b998a07cac54495800e41c9eb5305872 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:44:30 +0100 Subject: [PATCH 09/19] PDJB-268: Test explicitly the flag-off implementation does nothing --- .../services/JointLandlordInvitationExpiryServiceTests.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt index bd9e59c610..aeac80e570 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt @@ -188,6 +188,7 @@ class JointLandlordInvitationExpiryServiceTests { flagOff.expirePendingInvitations() - // No dependencies, no side effects - nothing to verify beyond not throwing. + verify(mockExpiryEmailNotificationService, never()).sendEmail(any(), any()) + verify(mockJointLandlordInvitationRepository, never()).save(any()) } } From 6f8c759cb9f37f97f064ccfc168ccb931bb43854 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:49:50 +0100 Subject: [PATCH 10/19] PDJB-268: Explain why we have literal strings since we can't automatically determine the task name --- .../prsdb/webapp/applications/PrsdbTaskApplicationTests.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/applications/PrsdbTaskApplicationTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/applications/PrsdbTaskApplicationTests.kt index 8054ecec1b..6be5c13830 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/applications/PrsdbTaskApplicationTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/applications/PrsdbTaskApplicationTests.kt @@ -79,6 +79,8 @@ class PrsdbTaskApplicationTests { PropertyOwnershipSearchRepositoryImpl::class.simpleBeanName, LandlordSearchRepositoryImpl::class.simpleBeanName, IncompletePropertiesService::class.simpleBeanName, + // when the feature flagged variant is removed the name overrides from JointLandlordInvitationExpiryService can be removed. + // then, this can be replaced by JointLandlordInvitationExpiryService::class.simpleBeanName "joint-landlord-invitation-expiry-flag-off", "joint-landlord-invitation-expiry-flag-on", ).map { it.lowercase() }.toSet() From 1833f5d686d848e54eb201699aa105f532adba65 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:26:15 +0100 Subject: [PATCH 11/19] Revert "PDJB-268: Remove unneeded orchestration from email regular jobs" This reverts commit 65e8c77224596f899656ecd072c5079d60ccb7a4. --- ...completePropertiesTaskApplicationRunner.kt | 13 ++++++- ...andlordInvitationsTaskApplicationRunner.kt | 11 +++++- ...etePropertiesTaskApplicationRunnerTests.kt | 39 +++++++++++++++++++ ...rdInvitationsTaskApplicationRunnerTests.kt | 39 +++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/DeleteIncompletePropertiesTaskApplicationRunnerTests.kt create mode 100644 src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/DeleteIncompletePropertiesTaskApplicationRunner.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/DeleteIncompletePropertiesTaskApplicationRunner.kt index 3e8f5803c7..f2e17a85c0 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/DeleteIncompletePropertiesTaskApplicationRunner.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/DeleteIncompletePropertiesTaskApplicationRunner.kt @@ -16,8 +16,8 @@ class DeleteIncompletePropertiesTaskApplicationRunner( override fun run(args: ApplicationArguments) { println("Executing delete incomplete properties scheduled task") - val numberOfRecordsDeleted = incompletePropertiesService.deleteIncompletePropertiesOlderThan28Days() - println("Deleted $numberOfRecordsDeleted incomplete properties.") + // Separating into its own method to allow this to be tested without "exitProcess" being called + deleteIncompletePropertiesTaskLogic() val code = SpringApplication.exit(context, { 0 }).also { @@ -25,4 +25,13 @@ class DeleteIncompletePropertiesTaskApplicationRunner( } exitProcess(code) } + + private fun deleteIncompletePropertiesTaskLogic() { + val numberOfRecordsDeleted = incompletePropertiesService.deleteIncompletePropertiesOlderThan28Days() + println("Deleted $numberOfRecordsDeleted incomplete properties.") + } + + companion object { + const val DELETE_INCOMPLETE_PROPERTIES_TASK_METHOD_NAME = "deleteIncompletePropertiesTaskLogic" + } } diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt index fbeae7a75f..434523c832 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt @@ -16,7 +16,8 @@ class ExpireJointLandlordInvitationsTaskApplicationRunner( override fun run(args: ApplicationArguments?) { println("Executing expire joint landlord invitations scheduled task") - jointLandlordInvitationExpiryService.expirePendingInvitations() + // Separating into its own method to allow this to be tested without "exitProcess" being called + expireJointLandlordInvitationsTaskLogic() val code = SpringApplication.exit(context, { 0 }).also { @@ -24,4 +25,12 @@ class ExpireJointLandlordInvitationsTaskApplicationRunner( } exitProcess(code) } + + private fun expireJointLandlordInvitationsTaskLogic() { + jointLandlordInvitationExpiryService.expirePendingInvitations() + } + + companion object { + const val EXPIRE_JOINT_LANDLORD_INVITATIONS_TASK_METHOD_NAME = "expireJointLandlordInvitationsTaskLogic" + } } diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/DeleteIncompletePropertiesTaskApplicationRunnerTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/DeleteIncompletePropertiesTaskApplicationRunnerTests.kt new file mode 100644 index 0000000000..cdb62ec88a --- /dev/null +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/DeleteIncompletePropertiesTaskApplicationRunnerTests.kt @@ -0,0 +1,39 @@ +package uk.gov.communities.prsdb.webapp.scheduledTaskRunners + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.verify +import org.springframework.context.ApplicationContext +import uk.gov.communities.prsdb.webapp.application.DeleteIncompletePropertiesTaskApplicationRunner +import uk.gov.communities.prsdb.webapp.application.DeleteIncompletePropertiesTaskApplicationRunner.Companion.DELETE_INCOMPLETE_PROPERTIES_TASK_METHOD_NAME +import uk.gov.communities.prsdb.webapp.services.IncompletePropertiesService + +@ExtendWith(MockitoExtension::class) +class DeleteIncompletePropertiesTaskApplicationRunnerTests { + @Mock + private lateinit var context: ApplicationContext + + @Mock + private lateinit var incompletePropertiesService: IncompletePropertiesService + + @InjectMocks + private lateinit var runner: DeleteIncompletePropertiesTaskApplicationRunner + + @Test + fun `deleteIncompletePropertiesTaskLogic calls service to delete incomplete properties older than 28 days`() { + // Arrange + val method = + DeleteIncompletePropertiesTaskApplicationRunner::class.java + .getDeclaredMethod(DELETE_INCOMPLETE_PROPERTIES_TASK_METHOD_NAME) + method.isAccessible = true + + // Act + method.invoke(runner) + + // Assert + verify(incompletePropertiesService).deleteIncompletePropertiesOlderThan28Days() + } +} diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt new file mode 100644 index 0000000000..2512c67ae4 --- /dev/null +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt @@ -0,0 +1,39 @@ +package uk.gov.communities.prsdb.webapp.scheduledTaskRunners + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.verify +import org.springframework.context.ApplicationContext +import uk.gov.communities.prsdb.webapp.application.ExpireJointLandlordInvitationsTaskApplicationRunner +import uk.gov.communities.prsdb.webapp.application.ExpireJointLandlordInvitationsTaskApplicationRunner.Companion.EXPIRE_JOINT_LANDLORD_INVITATIONS_TASK_METHOD_NAME +import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationExpiryService + +@ExtendWith(MockitoExtension::class) +class ExpireJointLandlordInvitationsTaskApplicationRunnerTests { + @Mock + private lateinit var context: ApplicationContext + + @Mock + private lateinit var jointLandlordInvitationExpiryService: JointLandlordInvitationExpiryService + + @InjectMocks + private lateinit var runner: ExpireJointLandlordInvitationsTaskApplicationRunner + + @Test + fun `expireJointLandlordInvitationsTaskLogic calls service to expire pending invitations`() { + // Arrange + val method = + ExpireJointLandlordInvitationsTaskApplicationRunner::class.java + .getDeclaredMethod(EXPIRE_JOINT_LANDLORD_INVITATIONS_TASK_METHOD_NAME) + method.isAccessible = true + + // Act + method.invoke(runner) + + // Assert + verify(jointLandlordInvitationExpiryService).expirePendingInvitations() + } +} From 1f0a64ed8f6c6d7a79bc6afa0399891329f75300 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:11:41 +0100 Subject: [PATCH 12/19] PDJB-928: Print IDs of expired invitations --- ...reJointLandlordInvitationsTaskApplicationRunner.kt | 8 +++++++- .../services/JointLandlordInvitationExpiryService.kt | 11 ++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt index 434523c832..b1a3dcd6e1 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt @@ -27,7 +27,13 @@ class ExpireJointLandlordInvitationsTaskApplicationRunner( } private fun expireJointLandlordInvitationsTaskLogic() { - jointLandlordInvitationExpiryService.expirePendingInvitations() + val expiredIds = jointLandlordInvitationExpiryService.expirePendingInvitations() + + expiredIds.forEach { id -> + println("Expired joint landlord invitation with id: $id") + } + + println("Expired ${expiredIds.size} joint landlord invitations.") } companion object { diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt index 52f7969bb5..7f438465f9 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt @@ -17,14 +17,15 @@ import java.time.temporal.ChronoUnit @PrsdbFlip(name = JOINT_LANDLORDS, alterBean = "joint-landlord-invitation-expiry-flag-on") interface JointLandlordInvitationExpiryService { - fun expirePendingInvitations() + fun expirePendingInvitations(): List } @Primary @PrsdbTaskService("joint-landlord-invitation-expiry-flag-off") class JointLandlordInvitationExpiryServiceImplFlagOff : JointLandlordInvitationExpiryService { - override fun expirePendingInvitations() { + override fun expirePendingInvitations(): List { // No-op: the joint-landlords feature is disabled, so we do not expire invitations. + return emptyList() } } @@ -34,21 +35,25 @@ class JointLandlordInvitationExpiryServiceImplFlagOn( private val expiryEmailNotificationService: EmailNotificationService, private val absoluteUrlProvider: AbsoluteUrlProvider, ) : JointLandlordInvitationExpiryService { - override fun expirePendingInvitations() { + override fun expirePendingInvitations(): List { val cutoff = Instant.now().minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS) val expiredInvitations = invitationRepository.findAllByExpiredFalseAndCreatedDateBefore(cutoff) + val expiredIds = mutableListOf() expiredInvitations.forEach { invitation -> try { sendExpiryEmailsForInvitation(invitation) invitation.markAsExpired() invitationRepository.save(invitation) + expiredIds.add(invitation.id) } catch (ex: PersistentEmailSendException) { printFailureMessage(ex, invitation) } catch (ex: TransientEmailSentException) { printFailureMessage(ex, invitation) } } + + return expiredIds } private fun sendExpiryEmailsForInvitation(invitation: JointLandlordInvitation) { From 7e2b6d98c553c7bd1a83cc57519f386f68bc821c Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:22:09 +0100 Subject: [PATCH 13/19] PDJB-928: Rename flag for expiry email sending makes it more clear that this flag is only there to stop expiry emails being sent again --- .../entity/JointLandlordInvitation.kt | 6 +-- .../JointLandlordInvitationRepository.kt | 2 +- .../JointLandlordInvitationExpiryService.kt | 4 +- ...ired_flag_to_joint_landlord_invitation.sql | 2 +- ...intLandlordInvitationExpiryServiceTests.kt | 38 ++++++------------- 5 files changed, 18 insertions(+), 34 deletions(-) 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 ffe3c0ec2f..5f0d9c0ce4 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 @@ -34,11 +34,11 @@ class JointLandlordInvitation( private set @Column(nullable = false) - var expired: Boolean = false + var invitationExpiredEmailSent: Boolean = false private set - fun markAsExpired() { - expired = true + fun markAsExpiredEmailSent() { + invitationExpiredEmailSent = true } constructor( 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 4e51390ce0..9a7a60f817 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 @@ -8,5 +8,5 @@ import java.util.UUID interface JointLandlordInvitationRepository : JpaRepository { fun findByToken(token: UUID): JointLandlordInvitation? - fun findAllByExpiredFalseAndCreatedDateBefore(cutoff: Instant): List + fun findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(cutoff: Instant): List } diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt index 7f438465f9..05fb8dd8d3 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt @@ -37,13 +37,13 @@ class JointLandlordInvitationExpiryServiceImplFlagOn( ) : JointLandlordInvitationExpiryService { override fun expirePendingInvitations(): List { val cutoff = Instant.now().minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS) - val expiredInvitations = invitationRepository.findAllByExpiredFalseAndCreatedDateBefore(cutoff) + val expiredInvitations = invitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(cutoff) val expiredIds = mutableListOf() expiredInvitations.forEach { invitation -> try { sendExpiryEmailsForInvitation(invitation) - invitation.markAsExpired() + invitation.markAsExpiredEmailSent() invitationRepository.save(invitation) expiredIds.add(invitation.id) } catch (ex: PersistentEmailSendException) { diff --git a/src/main/resources/db/migrations/V1_28_0__add_expired_flag_to_joint_landlord_invitation.sql b/src/main/resources/db/migrations/V1_28_0__add_expired_flag_to_joint_landlord_invitation.sql index 4e0cc73ead..f5a05a4070 100644 --- a/src/main/resources/db/migrations/V1_28_0__add_expired_flag_to_joint_landlord_invitation.sql +++ b/src/main/resources/db/migrations/V1_28_0__add_expired_flag_to_joint_landlord_invitation.sql @@ -1 +1 @@ -ALTER TABLE joint_landlord_invitation ADD COLUMN expired BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE joint_landlord_invitation ADD COLUMN invitation_expired_email_sent BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt index aeac80e570..4960e5231d 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt @@ -43,7 +43,7 @@ class JointLandlordInvitationExpiryServiceTests { @Test fun `expirePendingInvitations queries the repository with a cutoff of 28 days ago`() { - whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) + whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) .thenReturn(emptyList()) val beforeCall = Instant.now() @@ -51,7 +51,7 @@ class JointLandlordInvitationExpiryServiceTests { val afterCall = Instant.now() val cutoffCaptor = argumentCaptor() - verify(mockJointLandlordInvitationRepository).findAllByExpiredFalseAndCreatedDateBefore(cutoffCaptor.capture()) + verify(mockJointLandlordInvitationRepository).findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(cutoffCaptor.capture()) val expectedCutoffLowerBound = beforeCall.minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS) @@ -76,7 +76,7 @@ class JointLandlordInvitationExpiryServiceTests { ) val propertyRecordUri = URI("https://example.com/landlord/property/1") - whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) + whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) .thenReturn(listOf(invitation)) whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) .thenReturn(propertyRecordUri) @@ -103,7 +103,7 @@ class JointLandlordInvitationExpiryServiceTests { MockJointLandlordData.createJointLandlordInvitation(id = 3, email = "third@example.com"), ) - whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) + whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) .thenReturn(invitations) whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) .thenReturn(URI("https://example.com/landlord/property/1")) @@ -114,30 +114,14 @@ class JointLandlordInvitationExpiryServiceTests { } @Test - fun `expirePendingInvitations marks a non-expired invitation as expired`() { - val invitation = MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "test@example.com") - assert(!invitation.expired) { "Expected invitation to start as non-expired" } - - whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) - .thenReturn(listOf(invitation)) - whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) - .thenReturn(URI("https://example.com/landlord/property/1")) - - expiryService.expirePendingInvitations() - - assert(invitation.expired) { "Expected invitation to be marked as expired" } - verify(mockJointLandlordInvitationRepository).save(invitation) - } - - @Test - fun `expirePendingInvitations marks each invitation as expired and saves it after sending the email`() { + fun `expirePendingInvitations marks when expiry email is sent and saves it after sending the email`() { val invitations = listOf( MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "first@example.com"), MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "second@example.com"), ) - whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) + whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) .thenReturn(invitations) whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) .thenReturn(URI("https://example.com/landlord/property/1")) @@ -145,14 +129,14 @@ class JointLandlordInvitationExpiryServiceTests { expiryService.expirePendingInvitations() invitations.forEach { invitation -> - assert(invitation.expired) { "Expected invitation ${invitation.id} to be marked as expired" } + assert(invitation.invitationExpiredEmailSent) { "Expected invitation ${invitation.id} to be marked as expired" } verify(mockJointLandlordInvitationRepository).save(invitation) } } @Test fun `expirePendingInvitations does nothing when there are no expired invitations`() { - whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) + whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) .thenReturn(emptyList()) expiryService.expirePendingInvitations() @@ -166,7 +150,7 @@ class JointLandlordInvitationExpiryServiceTests { val failingInvitation = MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "fail@example.com") val succeedingInvitation = MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "ok@example.com") - whenever(mockJointLandlordInvitationRepository.findAllByExpiredFalseAndCreatedDateBefore(any())) + whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) .thenReturn(listOf(failingInvitation, succeedingInvitation)) whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) .thenReturn(URI("https://example.com/landlord/property/1")) @@ -177,9 +161,9 @@ class JointLandlordInvitationExpiryServiceTests { expiryService.expirePendingInvitations() verify(mockJointLandlordInvitationRepository, never()).save(failingInvitation) - assert(!failingInvitation.expired) { "Expected failing invitation to not be marked as expired" } + assert(!failingInvitation.invitationExpiredEmailSent) { "Expected failing invitation to not be marked as expired" } verify(mockJointLandlordInvitationRepository).save(succeedingInvitation) - assert(succeedingInvitation.expired) { "Expected succeeding invitation to be marked as expired" } + assert(succeedingInvitation.invitationExpiredEmailSent) { "Expected succeeding invitation to be marked as expired" } } @Test From d73dd7dc75e484123e16b61c56f77b52e5fba385 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:04:35 +0100 Subject: [PATCH 14/19] PDJB-268: Ensure only expired emails have emails sent --- .../webapp/services/JointLandlordInvitationExpiryService.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt index 05fb8dd8d3..5f233c757a 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt @@ -37,7 +37,10 @@ class JointLandlordInvitationExpiryServiceImplFlagOn( ) : JointLandlordInvitationExpiryService { override fun expirePendingInvitations(): List { val cutoff = Instant.now().minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS) - val expiredInvitations = invitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(cutoff) + val expiredInvitations = + invitationRepository + .findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(cutoff) + .filter { it.isExpired } // to be safe val expiredIds = mutableListOf() expiredInvitations.forEach { invitation -> From ff6eda266278b539948e01df853298ca7766209e Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:06:43 +0100 Subject: [PATCH 15/19] PDJB-268: Mark more appropriate todo ticket for the landlord split --- .../webapp/services/JointLandlordInvitationExpiryService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt index 5f233c757a..22be0fc45b 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt @@ -78,7 +78,7 @@ class JointLandlordInvitationExpiryServiceImplFlagOn( } } - // TODO PDJB-260: include accepted joint landlords once that data model exists. + // TODO PDJB-432: include accepted joint landlords once that data model exists. private fun getExpiryEmailRecipients(propertyOwnership: PropertyOwnership): List = listOf(propertyOwnership.primaryLandlord) private fun printFailureMessage( From 5f00cc85216e8b74a749d5421f78592bfcd95c63 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:13:56 +0100 Subject: [PATCH 16/19] PDJB-268: Rename services to reflect email sending over expiry --- ...andlordInvitationsTaskApplicationRunner.kt | 42 ------------------- ...itationExpiryEmailTaskApplicationRunner.kt | 42 +++++++++++++++++++ ...ntLandlordInvitationExpiryEmailService.kt} | 22 +++++----- .../applications/PrsdbTaskApplicationTests.kt | 8 ++-- ...rdInvitationsTaskApplicationRunnerTests.kt | 39 ----------------- ...onExpiryEmailTaskApplicationRunnerTests.kt | 39 +++++++++++++++++ ...dlordInvitationExpiryEmailServiceTests.kt} | 34 +++++++-------- 7 files changed, 113 insertions(+), 113 deletions(-) delete mode 100644 src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt create mode 100644 src/main/kotlin/uk/gov/communities/prsdb/webapp/application/JointLandlordInvitationExpiryEmailTaskApplicationRunner.kt rename src/main/kotlin/uk/gov/communities/prsdb/webapp/services/{JointLandlordInvitationExpiryService.kt => JointLandlordInvitationExpiryEmailService.kt} (85%) delete mode 100644 src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt create mode 100644 src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/JointLandlordInvitationExpiryEmailTaskApplicationRunnerTests.kt rename src/test/kotlin/uk/gov/communities/prsdb/webapp/services/{JointLandlordInvitationExpiryServiceTests.kt => JointLandlordInvitationExpiryEmailServiceTests.kt} (85%) diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt deleted file mode 100644 index b1a3dcd6e1..0000000000 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/ExpireJointLandlordInvitationsTaskApplicationRunner.kt +++ /dev/null @@ -1,42 +0,0 @@ -package uk.gov.communities.prsdb.webapp.application - -import org.springframework.boot.ApplicationArguments -import org.springframework.boot.ApplicationRunner -import org.springframework.boot.SpringApplication -import org.springframework.context.ApplicationContext -import uk.gov.communities.prsdb.webapp.annotations.taskAnnotations.PrsdbScheduledTask -import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationExpiryService -import kotlin.system.exitProcess - -@PrsdbScheduledTask("expire-joint-landlord-invitations-scheduled-task") -class ExpireJointLandlordInvitationsTaskApplicationRunner( - private val context: ApplicationContext, - private val jointLandlordInvitationExpiryService: JointLandlordInvitationExpiryService, -) : ApplicationRunner { - override fun run(args: ApplicationArguments?) { - println("Executing expire joint landlord invitations scheduled task") - - // Separating into its own method to allow this to be tested without "exitProcess" being called - expireJointLandlordInvitationsTaskLogic() - - val code = - SpringApplication.exit(context, { 0 }).also { - println("Scheduled task executed. Application will exit now.") - } - exitProcess(code) - } - - private fun expireJointLandlordInvitationsTaskLogic() { - val expiredIds = jointLandlordInvitationExpiryService.expirePendingInvitations() - - expiredIds.forEach { id -> - println("Expired joint landlord invitation with id: $id") - } - - println("Expired ${expiredIds.size} joint landlord invitations.") - } - - companion object { - const val EXPIRE_JOINT_LANDLORD_INVITATIONS_TASK_METHOD_NAME = "expireJointLandlordInvitationsTaskLogic" - } -} diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/JointLandlordInvitationExpiryEmailTaskApplicationRunner.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/JointLandlordInvitationExpiryEmailTaskApplicationRunner.kt new file mode 100644 index 0000000000..f9c775a99e --- /dev/null +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/JointLandlordInvitationExpiryEmailTaskApplicationRunner.kt @@ -0,0 +1,42 @@ +package uk.gov.communities.prsdb.webapp.application + +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.boot.SpringApplication +import org.springframework.context.ApplicationContext +import uk.gov.communities.prsdb.webapp.annotations.taskAnnotations.PrsdbScheduledTask +import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationExpiryEmailService +import kotlin.system.exitProcess + +@PrsdbScheduledTask("joint-landlord-invitation-expiry-email-scheduled-task") +class JointLandlordInvitationExpiryEmailTaskApplicationRunner( + private val context: ApplicationContext, + private val jointLandlordInvitationExpiryEmailService: JointLandlordInvitationExpiryEmailService, +) : ApplicationRunner { + override fun run(args: ApplicationArguments?) { + println("Executing joint landlord invitation expiry email scheduled task") + + // Separating into its own method to allow this to be tested without "exitProcess" being called + sendJointLandlordInvitationExpiryEmailsTaskLogic() + + val code = + SpringApplication.exit(context, { 0 }).also { + println("Scheduled task executed. Application will exit now.") + } + exitProcess(code) + } + + private fun sendJointLandlordInvitationExpiryEmailsTaskLogic() { + val processedIds = jointLandlordInvitationExpiryEmailService.sendExpiryEmailsForExpiredInvitations() + + processedIds.forEach { id -> + println("Sent expiry email for joint landlord invitation with id: $id") + } + + println("Sent expiry emails for ${processedIds.size} joint landlord invitations.") + } + + companion object { + const val SEND_JOINT_LANDLORD_INVITATION_EXPIRY_EMAILS_TASK_METHOD_NAME = "sendJointLandlordInvitationExpiryEmailsTaskLogic" + } +} diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailService.kt similarity index 85% rename from src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt rename to src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailService.kt index 22be0fc45b..0fedf2ab4d 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryService.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailService.kt @@ -15,27 +15,27 @@ import uk.gov.communities.prsdb.webapp.models.viewModels.emailModels.JointLandlo import java.time.Instant import java.time.temporal.ChronoUnit -@PrsdbFlip(name = JOINT_LANDLORDS, alterBean = "joint-landlord-invitation-expiry-flag-on") -interface JointLandlordInvitationExpiryService { - fun expirePendingInvitations(): List +@PrsdbFlip(name = JOINT_LANDLORDS, alterBean = "joint-landlord-invitation-expiry-email-flag-on") +interface JointLandlordInvitationExpiryEmailService { + fun sendExpiryEmailsForExpiredInvitations(): List } @Primary -@PrsdbTaskService("joint-landlord-invitation-expiry-flag-off") -class JointLandlordInvitationExpiryServiceImplFlagOff : JointLandlordInvitationExpiryService { - override fun expirePendingInvitations(): List { - // No-op: the joint-landlords feature is disabled, so we do not expire invitations. +@PrsdbTaskService("joint-landlord-invitation-expiry-email-flag-off") +class JointLandlordInvitationExpiryEmailServiceImplFlagOff : JointLandlordInvitationExpiryEmailService { + override fun sendExpiryEmailsForExpiredInvitations(): List { + // No-op: the joint-landlords feature is disabled, so we do not send expiry emails. return emptyList() } } -@PrsdbTaskService("joint-landlord-invitation-expiry-flag-on") -class JointLandlordInvitationExpiryServiceImplFlagOn( +@PrsdbTaskService("joint-landlord-invitation-expiry-email-flag-on") +class JointLandlordInvitationExpiryEmailServiceImplFlagOn( private val invitationRepository: JointLandlordInvitationRepository, private val expiryEmailNotificationService: EmailNotificationService, private val absoluteUrlProvider: AbsoluteUrlProvider, -) : JointLandlordInvitationExpiryService { - override fun expirePendingInvitations(): List { +) : JointLandlordInvitationExpiryEmailService { + override fun sendExpiryEmailsForExpiredInvitations(): List { val cutoff = Instant.now().minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS) val expiredInvitations = invitationRepository diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/applications/PrsdbTaskApplicationTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/applications/PrsdbTaskApplicationTests.kt index 6be5c13830..576c6b18ff 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/applications/PrsdbTaskApplicationTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/applications/PrsdbTaskApplicationTests.kt @@ -79,10 +79,10 @@ class PrsdbTaskApplicationTests { PropertyOwnershipSearchRepositoryImpl::class.simpleBeanName, LandlordSearchRepositoryImpl::class.simpleBeanName, IncompletePropertiesService::class.simpleBeanName, - // when the feature flagged variant is removed the name overrides from JointLandlordInvitationExpiryService can be removed. - // then, this can be replaced by JointLandlordInvitationExpiryService::class.simpleBeanName - "joint-landlord-invitation-expiry-flag-off", - "joint-landlord-invitation-expiry-flag-on", + // when the feature flagged variant is removed the name overrides from JointLandlordInvitationExpiryEmailService can be removed. + // then, this can be replaced by JointLandlordInvitationExpiryEmailService::class.simpleBeanName + "joint-landlord-invitation-expiry-email-flag-off", + "joint-landlord-invitation-expiry-email-flag-on", ).map { it.lowercase() }.toSet() val beanNames = ApplicationTestHelper.getAvailableBeanNames(context!!) diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt deleted file mode 100644 index 2512c67ae4..0000000000 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/ExpireJointLandlordInvitationsTaskApplicationRunnerTests.kt +++ /dev/null @@ -1,39 +0,0 @@ -package uk.gov.communities.prsdb.webapp.scheduledTaskRunners - -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.InjectMocks -import org.mockito.Mock -import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.verify -import org.springframework.context.ApplicationContext -import uk.gov.communities.prsdb.webapp.application.ExpireJointLandlordInvitationsTaskApplicationRunner -import uk.gov.communities.prsdb.webapp.application.ExpireJointLandlordInvitationsTaskApplicationRunner.Companion.EXPIRE_JOINT_LANDLORD_INVITATIONS_TASK_METHOD_NAME -import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationExpiryService - -@ExtendWith(MockitoExtension::class) -class ExpireJointLandlordInvitationsTaskApplicationRunnerTests { - @Mock - private lateinit var context: ApplicationContext - - @Mock - private lateinit var jointLandlordInvitationExpiryService: JointLandlordInvitationExpiryService - - @InjectMocks - private lateinit var runner: ExpireJointLandlordInvitationsTaskApplicationRunner - - @Test - fun `expireJointLandlordInvitationsTaskLogic calls service to expire pending invitations`() { - // Arrange - val method = - ExpireJointLandlordInvitationsTaskApplicationRunner::class.java - .getDeclaredMethod(EXPIRE_JOINT_LANDLORD_INVITATIONS_TASK_METHOD_NAME) - method.isAccessible = true - - // Act - method.invoke(runner) - - // Assert - verify(jointLandlordInvitationExpiryService).expirePendingInvitations() - } -} diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/JointLandlordInvitationExpiryEmailTaskApplicationRunnerTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/JointLandlordInvitationExpiryEmailTaskApplicationRunnerTests.kt new file mode 100644 index 0000000000..f0a60cea07 --- /dev/null +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/scheduledTaskRunners/JointLandlordInvitationExpiryEmailTaskApplicationRunnerTests.kt @@ -0,0 +1,39 @@ +package uk.gov.communities.prsdb.webapp.scheduledTaskRunners + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.verify +import org.springframework.context.ApplicationContext +import uk.gov.communities.prsdb.webapp.application.JointLandlordInvitationExpiryEmailTaskApplicationRunner +import uk.gov.communities.prsdb.webapp.application.JointLandlordInvitationExpiryEmailTaskApplicationRunner.Companion.SEND_JOINT_LANDLORD_INVITATION_EXPIRY_EMAILS_TASK_METHOD_NAME +import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationExpiryEmailService + +@ExtendWith(MockitoExtension::class) +class JointLandlordInvitationExpiryEmailTaskApplicationRunnerTests { + @Mock + private lateinit var context: ApplicationContext + + @Mock + private lateinit var jointLandlordInvitationExpiryEmailService: JointLandlordInvitationExpiryEmailService + + @InjectMocks + private lateinit var runner: JointLandlordInvitationExpiryEmailTaskApplicationRunner + + @Test + fun `sendJointLandlordInvitationExpiryEmailsTaskLogic calls service to send expiry emails`() { + // Arrange + val method = + JointLandlordInvitationExpiryEmailTaskApplicationRunner::class.java + .getDeclaredMethod(SEND_JOINT_LANDLORD_INVITATION_EXPIRY_EMAILS_TASK_METHOD_NAME) + method.isAccessible = true + + // Act + method.invoke(runner) + + // Assert + verify(jointLandlordInvitationExpiryEmailService).sendExpiryEmailsForExpiredInvitations() + } +} diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailServiceTests.kt similarity index 85% rename from src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt rename to src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailServiceTests.kt index 4960e5231d..98f41c82a6 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryServiceTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailServiceTests.kt @@ -22,11 +22,11 @@ import java.net.URI import java.time.Instant import java.time.temporal.ChronoUnit -class JointLandlordInvitationExpiryServiceTests { +class JointLandlordInvitationExpiryEmailServiceTests { private lateinit var mockJointLandlordInvitationRepository: JointLandlordInvitationRepository private lateinit var mockExpiryEmailNotificationService: EmailNotificationService private lateinit var mockAbsoluteUrlProvider: AbsoluteUrlProvider - private lateinit var expiryService: JointLandlordInvitationExpiryServiceImplFlagOn + private lateinit var expiryService: JointLandlordInvitationExpiryEmailServiceImplFlagOn @BeforeEach fun setup() { @@ -34,7 +34,7 @@ class JointLandlordInvitationExpiryServiceTests { mockExpiryEmailNotificationService = mock() mockAbsoluteUrlProvider = mock() expiryService = - JointLandlordInvitationExpiryServiceImplFlagOn( + JointLandlordInvitationExpiryEmailServiceImplFlagOn( mockJointLandlordInvitationRepository, mockExpiryEmailNotificationService, mockAbsoluteUrlProvider, @@ -42,12 +42,12 @@ class JointLandlordInvitationExpiryServiceTests { } @Test - fun `expirePendingInvitations queries the repository with a cutoff of 28 days ago`() { + fun `sendExpiryEmailsForExpiredInvitations queries the repository with a cutoff of 28 days ago`() { whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) .thenReturn(emptyList()) val beforeCall = Instant.now() - expiryService.expirePendingInvitations() + expiryService.sendExpiryEmailsForExpiredInvitations() val afterCall = Instant.now() val cutoffCaptor = argumentCaptor() @@ -65,7 +65,7 @@ class JointLandlordInvitationExpiryServiceTests { } @Test - fun `expirePendingInvitations sends expiry email to the primary landlord for each expired invitation`() { + fun `sendExpiryEmailsForExpiredInvitations sends expiry email to the primary landlord for each expired invitation`() { val primaryLandlord = MockLandlordData.createLandlord(name = "Lois", email = "lois@example.com") val address = MockLandlordData.createAddress(singleLineAddress = "Flat 1, 11 Elm Drive, London, NW8 2DK") val propertyOwnership = MockLandlordData.createPropertyOwnership(primaryLandlord = primaryLandlord, address = address) @@ -81,7 +81,7 @@ class JointLandlordInvitationExpiryServiceTests { whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) .thenReturn(propertyRecordUri) - expiryService.expirePendingInvitations() + expiryService.sendExpiryEmailsForExpiredInvitations() val emailModelCaptor = argumentCaptor() verify(mockExpiryEmailNotificationService).sendEmail(eq("lois@example.com"), emailModelCaptor.capture()) @@ -95,7 +95,7 @@ class JointLandlordInvitationExpiryServiceTests { } @Test - fun `expirePendingInvitations sends one email per expired invitation`() { + fun `sendExpiryEmailsForExpiredInvitations sends one email per expired invitation`() { val invitations = listOf( MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "first@example.com"), @@ -108,13 +108,13 @@ class JointLandlordInvitationExpiryServiceTests { whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) .thenReturn(URI("https://example.com/landlord/property/1")) - expiryService.expirePendingInvitations() + expiryService.sendExpiryEmailsForExpiredInvitations() verify(mockExpiryEmailNotificationService, times(3)).sendEmail(any(), any()) } @Test - fun `expirePendingInvitations marks when expiry email is sent and saves it after sending the email`() { + fun `sendExpiryEmailsForExpiredInvitations marks when expiry email is sent and saves it after sending the email`() { val invitations = listOf( MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "first@example.com"), @@ -126,7 +126,7 @@ class JointLandlordInvitationExpiryServiceTests { whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) .thenReturn(URI("https://example.com/landlord/property/1")) - expiryService.expirePendingInvitations() + expiryService.sendExpiryEmailsForExpiredInvitations() invitations.forEach { invitation -> assert(invitation.invitationExpiredEmailSent) { "Expected invitation ${invitation.id} to be marked as expired" } @@ -135,18 +135,18 @@ class JointLandlordInvitationExpiryServiceTests { } @Test - fun `expirePendingInvitations does nothing when there are no expired invitations`() { + fun `sendExpiryEmailsForExpiredInvitations does nothing when there are no expired invitations`() { whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) .thenReturn(emptyList()) - expiryService.expirePendingInvitations() + expiryService.sendExpiryEmailsForExpiredInvitations() verify(mockExpiryEmailNotificationService, never()).sendEmail(any(), any()) verify(mockJointLandlordInvitationRepository, never()).save(any()) } @Test - fun `expirePendingInvitations continues processing and does not delete the failed invitation when an email send fails`() { + fun `sendExpiryEmailsForExpiredInvitations continues processing and does not delete the failed invitation when an email send fails`() { val failingInvitation = MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "fail@example.com") val succeedingInvitation = MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "ok@example.com") @@ -158,7 +158,7 @@ class JointLandlordInvitationExpiryServiceTests { .thenThrow(PersistentEmailSendException("boom")) .thenAnswer { /* succeed on the second call */ } - expiryService.expirePendingInvitations() + expiryService.sendExpiryEmailsForExpiredInvitations() verify(mockJointLandlordInvitationRepository, never()).save(failingInvitation) assert(!failingInvitation.invitationExpiredEmailSent) { "Expected failing invitation to not be marked as expired" } @@ -168,9 +168,9 @@ class JointLandlordInvitationExpiryServiceTests { @Test fun `flag-off implementation does nothing`() { - val flagOff = JointLandlordInvitationExpiryServiceImplFlagOff() + val flagOff = JointLandlordInvitationExpiryEmailServiceImplFlagOff() - flagOff.expirePendingInvitations() + flagOff.sendExpiryEmailsForExpiredInvitations() verify(mockExpiryEmailNotificationService, never()).sendEmail(any(), any()) verify(mockJointLandlordInvitationRepository, never()).save(any()) From 81e9a42baf2c7314131fba9d34d206ab4ca283dc Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:29:06 +0100 Subject: [PATCH 17/19] PDJB-268: Fix migration version --- ...=> V1_29_0__add_expired_flag_to_joint_landlord_invitation.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migrations/{V1_28_0__add_expired_flag_to_joint_landlord_invitation.sql => V1_29_0__add_expired_flag_to_joint_landlord_invitation.sql} (100%) diff --git a/src/main/resources/db/migrations/V1_28_0__add_expired_flag_to_joint_landlord_invitation.sql b/src/main/resources/db/migrations/V1_29_0__add_expired_flag_to_joint_landlord_invitation.sql similarity index 100% rename from src/main/resources/db/migrations/V1_28_0__add_expired_flag_to_joint_landlord_invitation.sql rename to src/main/resources/db/migrations/V1_29_0__add_expired_flag_to_joint_landlord_invitation.sql From b5a01529bdafde5c4e3bfc841a63c33b822ad56e Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:35:19 +0100 Subject: [PATCH 18/19] PDJB-268: Fix tests --- ...ndlordInvitationExpiryEmailServiceTests.kt | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailServiceTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailServiceTests.kt index 98f41c82a6..c8d6480578 100644 --- a/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailServiceTests.kt +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailServiceTests.kt @@ -28,6 +28,9 @@ class JointLandlordInvitationExpiryEmailServiceTests { private lateinit var mockAbsoluteUrlProvider: AbsoluteUrlProvider private lateinit var expiryService: JointLandlordInvitationExpiryEmailServiceImplFlagOn + private val expiredCreatedDate: Instant = + Instant.now().minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong() + 1, ChronoUnit.DAYS) + @BeforeEach fun setup() { mockJointLandlordInvitationRepository = mock() @@ -73,6 +76,7 @@ class JointLandlordInvitationExpiryEmailServiceTests { MockJointLandlordData.createJointLandlordInvitation( email = "very-real-email@example.com", propertyOwnership = propertyOwnership, + createdDate = expiredCreatedDate, ) val propertyRecordUri = URI("https://example.com/landlord/property/1") @@ -98,9 +102,9 @@ class JointLandlordInvitationExpiryEmailServiceTests { fun `sendExpiryEmailsForExpiredInvitations sends one email per expired invitation`() { val invitations = listOf( - MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "first@example.com"), - MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "second@example.com"), - MockJointLandlordData.createJointLandlordInvitation(id = 3, email = "third@example.com"), + MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "first@example.com", createdDate = expiredCreatedDate), + MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "second@example.com", createdDate = expiredCreatedDate), + MockJointLandlordData.createJointLandlordInvitation(id = 3, email = "third@example.com", createdDate = expiredCreatedDate), ) whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) @@ -117,8 +121,8 @@ class JointLandlordInvitationExpiryEmailServiceTests { fun `sendExpiryEmailsForExpiredInvitations marks when expiry email is sent and saves it after sending the email`() { val invitations = listOf( - MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "first@example.com"), - MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "second@example.com"), + MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "first@example.com", createdDate = expiredCreatedDate), + MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "second@example.com", createdDate = expiredCreatedDate), ) whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) @@ -147,8 +151,18 @@ class JointLandlordInvitationExpiryEmailServiceTests { @Test fun `sendExpiryEmailsForExpiredInvitations continues processing and does not delete the failed invitation when an email send fails`() { - val failingInvitation = MockJointLandlordData.createJointLandlordInvitation(id = 1, email = "fail@example.com") - val succeedingInvitation = MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "ok@example.com") + val failingInvitation = + MockJointLandlordData.createJointLandlordInvitation( + id = 1, + email = "fail@example.com", + createdDate = expiredCreatedDate, + ) + val succeedingInvitation = + MockJointLandlordData.createJointLandlordInvitation( + id = 2, + email = "ok@example.com", + createdDate = expiredCreatedDate, + ) whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) .thenReturn(listOf(failingInvitation, succeedingInvitation)) From e36cabc92cf1b8771e5adb5c415c4f438052c879 Mon Sep 17 00:00:00 2001 From: samyou-softwire <108681823+samyou-softwire@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:42:00 +0100 Subject: [PATCH 19/19] PDJB-268: Shorten scheduled task name too long for the infra repo, see https://github.com/communitiesuk/prsdb-infra/actions/runs/27007489616/job/79702624917 --- .../JointLandlordInvitationExpiryEmailTaskApplicationRunner.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/JointLandlordInvitationExpiryEmailTaskApplicationRunner.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/JointLandlordInvitationExpiryEmailTaskApplicationRunner.kt index f9c775a99e..7aedc2499f 100644 --- a/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/JointLandlordInvitationExpiryEmailTaskApplicationRunner.kt +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/application/JointLandlordInvitationExpiryEmailTaskApplicationRunner.kt @@ -8,7 +8,7 @@ import uk.gov.communities.prsdb.webapp.annotations.taskAnnotations.PrsdbSchedule import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationExpiryEmailService import kotlin.system.exitProcess -@PrsdbScheduledTask("joint-landlord-invitation-expiry-email-scheduled-task") +@PrsdbScheduledTask("jl-invitation-expiry-email-scheduled-task") class JointLandlordInvitationExpiryEmailTaskApplicationRunner( private val context: ApplicationContext, private val jointLandlordInvitationExpiryEmailService: JointLandlordInvitationExpiryEmailService,