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..7aedc2499f --- /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("jl-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/constants/InvitationLifetimeConstants.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/constants/InvitationLifetimeConstants.kt index a0be34b51d..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_DAYS = 28 +const val JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS: Int = 28 diff --git a/src/main/kotlin/uk/gov/communities/prsdb/webapp/database/entity/JointLandlordInvitation.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/database/entity/JointLandlordInvitation.kt index a8004502d9..07db94db01 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 @@ -39,6 +39,14 @@ class JointLandlordInvitation( lateinit var invitingLandlord: Landlord private set + @Column(nullable = false) + var invitationExpiredEmailSent: Boolean = false + private set + + fun markAsExpiredEmailSent() { + invitationExpiredEmailSent = 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 ed458b01a5..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 @@ -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 findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(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..8355de3bd5 --- /dev/null +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/JointLandlordInvitationExpiryEmail.kt @@ -0,0 +1,28 @@ +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, + 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 + + override fun toHashMap(): HashMap = + hashMapOf( + recipientNameKey to recipientName, + 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/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/JointLandlordInvitationExpiryEmailService.kt b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailService.kt new file mode 100644 index 0000000000..0fedf2ab4d --- /dev/null +++ b/src/main/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailService.kt @@ -0,0 +1,92 @@ +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.constants.JOINT_LANDLORDS +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 +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-email-flag-on") +interface JointLandlordInvitationExpiryEmailService { + fun sendExpiryEmailsForExpiredInvitations(): List +} + +@Primary +@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-email-flag-on") +class JointLandlordInvitationExpiryEmailServiceImplFlagOn( + private val invitationRepository: JointLandlordInvitationRepository, + private val expiryEmailNotificationService: EmailNotificationService, + private val absoluteUrlProvider: AbsoluteUrlProvider, +) : JointLandlordInvitationExpiryEmailService { + override fun sendExpiryEmailsForExpiredInvitations(): List { + val cutoff = Instant.now().minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS) + val expiredInvitations = + invitationRepository + .findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(cutoff) + .filter { it.isExpired } // to be safe + val expiredIds = mutableListOf() + + expiredInvitations.forEach { invitation -> + try { + sendExpiryEmailsForInvitation(invitation) + invitation.markAsExpiredEmailSent() + 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) { + 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, + expiryDays = JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS, + ), + ) + } + } + + // TODO PDJB-432: 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/db/migrations/V1_29_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 new file mode 100644 index 0000000000..f5a05a4070 --- /dev/null +++ b/src/main/resources/db/migrations/V1_29_0__add_expired_flag_to_joint_landlord_invitation.sql @@ -0,0 +1 @@ +ALTER TABLE joint_landlord_invitation ADD COLUMN invitation_expired_email_sent BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/main/resources/emails/JointLandlordInvitationExpiry.md b/src/main/resources/emails/JointLandlordInvitationExpiry.md new file mode 100644 index 0000000000..f5572f7ac1 --- /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 ((expiryDays)) 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. \ No newline at end of file diff --git a/src/main/resources/emails/emailTemplates.json b/src/main/resources/emails/emailTemplates.json index b0a071dc92..b3f1e7eb86 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": "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" } ] 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..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,6 +79,10 @@ class PrsdbTaskApplicationTests { PropertyOwnershipSearchRepositoryImpl::class.simpleBeanName, LandlordSearchRepositoryImpl::class.simpleBeanName, IncompletePropertiesService::class.simpleBeanName, + // 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/models/viewModels/emailModels/EmailTemplateModelsTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/models/viewModels/emailModels/EmailTemplateModelsTests.kt index 26c40cf1f3..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 @@ -172,6 +172,16 @@ 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"), + expiryDays = 28, + ), + "/emails/JointLandlordInvitationExpiry.md", + ), ) } 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/JointLandlordInvitationExpiryEmailServiceTests.kt b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailServiceTests.kt new file mode 100644 index 0000000000..c8d6480578 --- /dev/null +++ b/src/test/kotlin/uk/gov/communities/prsdb/webapp/services/JointLandlordInvitationExpiryEmailServiceTests.kt @@ -0,0 +1,192 @@ +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_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 +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 JointLandlordInvitationExpiryEmailServiceTests { + private lateinit var mockJointLandlordInvitationRepository: JointLandlordInvitationRepository + private lateinit var mockExpiryEmailNotificationService: EmailNotificationService + 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() + mockExpiryEmailNotificationService = mock() + mockAbsoluteUrlProvider = mock() + expiryService = + JointLandlordInvitationExpiryEmailServiceImplFlagOn( + mockJointLandlordInvitationRepository, + mockExpiryEmailNotificationService, + mockAbsoluteUrlProvider, + ) + } + + @Test + fun `sendExpiryEmailsForExpiredInvitations queries the repository with a cutoff of 28 days ago`() { + whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) + .thenReturn(emptyList()) + + val beforeCall = Instant.now() + expiryService.sendExpiryEmailsForExpiredInvitations() + val afterCall = Instant.now() + + val cutoffCaptor = argumentCaptor() + verify(mockJointLandlordInvitationRepository).findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(cutoffCaptor.capture()) + + val expectedCutoffLowerBound = + beforeCall.minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS) + val expectedCutoffUpperBound = + afterCall.minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS) + val actualCutoff = cutoffCaptor.firstValue + + assert(actualCutoff in expectedCutoffLowerBound..expectedCutoffUpperBound) { + "Cutoff $actualCutoff was outside expected window [$expectedCutoffLowerBound, $expectedCutoffUpperBound]" + } + } + + @Test + 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) + val invitation = + MockJointLandlordData.createJointLandlordInvitation( + email = "very-real-email@example.com", + propertyOwnership = propertyOwnership, + createdDate = expiredCreatedDate, + ) + val propertyRecordUri = URI("https://example.com/landlord/property/1") + + whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) + .thenReturn(listOf(invitation)) + whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) + .thenReturn(propertyRecordUri) + + expiryService.sendExpiryEmailsForExpiredInvitations() + + 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) + assertEquals(28, sentEmail.expiryDays) + } + + @Test + fun `sendExpiryEmailsForExpiredInvitations sends one email per expired invitation`() { + val invitations = + listOf( + 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())) + .thenReturn(invitations) + whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) + .thenReturn(URI("https://example.com/landlord/property/1")) + + expiryService.sendExpiryEmailsForExpiredInvitations() + + verify(mockExpiryEmailNotificationService, times(3)).sendEmail(any(), any()) + } + + @Test + 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", createdDate = expiredCreatedDate), + MockJointLandlordData.createJointLandlordInvitation(id = 2, email = "second@example.com", createdDate = expiredCreatedDate), + ) + + whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) + .thenReturn(invitations) + whenever(mockAbsoluteUrlProvider.buildLandlordPropertyDetailsUri(any())) + .thenReturn(URI("https://example.com/landlord/property/1")) + + expiryService.sendExpiryEmailsForExpiredInvitations() + + invitations.forEach { invitation -> + assert(invitation.invitationExpiredEmailSent) { "Expected invitation ${invitation.id} to be marked as expired" } + verify(mockJointLandlordInvitationRepository).save(invitation) + } + } + + @Test + fun `sendExpiryEmailsForExpiredInvitations does nothing when there are no expired invitations`() { + whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(any())) + .thenReturn(emptyList()) + + expiryService.sendExpiryEmailsForExpiredInvitations() + + verify(mockExpiryEmailNotificationService, never()).sendEmail(any(), any()) + verify(mockJointLandlordInvitationRepository, never()).save(any()) + } + + @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", + createdDate = expiredCreatedDate, + ) + val succeedingInvitation = + MockJointLandlordData.createJointLandlordInvitation( + id = 2, + email = "ok@example.com", + createdDate = expiredCreatedDate, + ) + + whenever(mockJointLandlordInvitationRepository.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(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.sendExpiryEmailsForExpiredInvitations() + + verify(mockJointLandlordInvitationRepository, never()).save(failingInvitation) + assert(!failingInvitation.invitationExpiredEmailSent) { "Expected failing invitation to not be marked as expired" } + verify(mockJointLandlordInvitationRepository).save(succeedingInvitation) + assert(succeedingInvitation.invitationExpiredEmailSent) { "Expected succeeding invitation to be marked as expired" } + } + + @Test + fun `flag-off implementation does nothing`() { + val flagOff = JointLandlordInvitationExpiryEmailServiceImplFlagOff() + + flagOff.sendExpiryEmailsForExpiredInvitations() + + verify(mockExpiryEmailNotificationService, never()).sendEmail(any(), any()) + verify(mockJointLandlordInvitationRepository, never()).save(any()) + } +}