Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ import java.time.LocalDateTime
* - `ADD` with no user external id enqueues `SyncContact` and throws
* a retryable exception so the retry picks up after the contact
* has materialised externally.
* - `ADD` with no cohort target id fails terminally. An operator must
* explicitly create or link a target before retrying the membership push.
* - `ADD` with no cohort target id is deferred until an operator links a
* target and the cohort reconcile job re-enqueues the missing membership.
* - `REMOVE` with no external state on either side is a no-op —
* there is nothing to converge to.
*/
Expand Down Expand Up @@ -85,7 +85,13 @@ class CohortMembershipSyncService(
}
val externalCohortId = targetIds.find(cohort)
if (externalCohortId == null) {
throw CohortTargetNotLinkedException(cohortId, system)
log.warn(
"Deferring membership add for user {} to {} cohort {} because no target is linked; member will converge after target link reconcile",
userId,
system,
cohortId,
)
return
}
outsideTransaction.executeWithoutResult { port.addMember(externalUserId, externalCohortId) }

Expand Down Expand Up @@ -126,7 +132,3 @@ class CohortMembershipSyncService(
* the prerequisite job has had a chance to complete.
*/
class CohortMembershipNotReadyException(message: String) : RuntimeException(message)

class CohortTargetNotLinkedException(cohortId: Long, system: String) : NonRetryableJobException(
"cohort $cohortId has no $system target — create or link an external target, then retry the membership job",
)
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ class CohortTargetingService(
propagationBehavior = TransactionDefinition.PROPAGATION_NOT_SUPPORTED
}

override fun linkExisting(subjectId: Long, system: TargetSystem, externalId: String): CohortMappingRow =
writeTransaction.execute {
override fun linkExisting(subjectId: Long, system: TargetSystem, externalId: String): CohortMappingRow {
val linked = writeTransaction.execute {
val subject = requireSubject(subjectId)
val existing = cohortRepo.findBySubjectIdAndSystem(subjectId, system.name)
val cohort = if (existing == null) {
Expand All @@ -62,7 +62,11 @@ class CohortTargetingService(
}
targetIds.record(cohort, externalId)
CohortMappingRow(cohort, externalId)
}!!
}

jobs.enqueue(CohortJobs.ReconcileList, CohortJobs.ReconcileListPayload(linked.cohort.id!!))
return linked
}

override fun create(subjectId: Long, system: TargetSystem, label: String, folderHint: String?): CohortMappingRow {
// Validate before touching the provider so a duplicate/missing subject
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import net.blueshell.api.platform.integration.cohort.port.out.CohortPortRegistry
import net.blueshell.api.platform.integration.sync.application.ExternalIdMappingService
import net.blueshell.api.platform.integration.sync.persistence.ExternalIdMapping
import net.blueshell.api.shared.enums.TargetSystem
import net.blueshell.api.shared.job.CohortJobs
import net.blueshell.api.shared.job.ContactJobs
import net.blueshell.api.shared.job.NonRetryableJobException
import net.blueshell.api.shared.job.TrackedJobDispatcher
import org.assertj.core.api.Assertions.assertThatCode
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import java.util.Optional
Expand Down Expand Up @@ -60,21 +60,17 @@ class CohortMembershipSyncServiceTest {
}

@Test
fun `ADD without a cohort target fails terminally and does not enqueue materialization`() {
fun `ADD without a cohort target is a benign no-op until a target is linked`() {
givenCohort(id = 10L, system = "BREVO", label = "Members")
every { externalIds.find("USER", 1L, "BREVO") } returns mapping("USER", 1L, "BREVO", "777")
every { targetIds.find(any()) } returns null

assertThatThrownBy {
assertThatCode {
service.sync(userId = 1L, cohortId = 10L, intent = SyncCohortMembershipIntent.ADD)
}.isInstanceOf(NonRetryableJobException::class.java)
.hasMessageContaining("cohort 10 has no BREVO target")
}.doesNotThrowAnyException()

verify(exactly = 0) {
jobs.enqueue(CohortJobs.MaterializeCohortTarget, any<CohortJobs.MaterializeCohortTargetPayload>())
}
verify(exactly = 0) { brevoPort.addMember(any(), any()) }
verify(exactly = 0) { brevoPort.createCohort(any(), any()) }
verify(exactly = 0) { ledger.markPushed(any(), any(), any(), any()) }
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ class CohortTargetingServiceTest {
}

@Test
fun `linkExisting fills an existing unbound mapping`() {
fun `linkExisting fills an existing unbound mapping and enqueues reconcile`() {
val subject = mock<net.blueshell.api.platform.integration.cohort.persistence.CohortSubject>()
val cohort = mock<Cohort> {
on { id } doReturn 7L
Expand All @@ -124,6 +124,10 @@ class CohortTargetingServiceTest {

verify(cohortRepo, never()).save(any())
verify(targetIds).record(cohort, "list-123")
verify(jobs).enqueue(
eq(CohortJobs.ReconcileList),
eq(CohortJobs.ReconcileListPayload(7L)),
)
assert(row.cohort == cohort)
assert(row.externalId == "list-123")
}
Expand Down
Loading