From bdae7e6745302610b34a42f241a06495b31d0a50 Mon Sep 17 00:00:00 2001 From: Agents Agent Date: Tue, 30 Jun 2026 12:57:15 +0000 Subject: [PATCH 1/2] test(cohort): reproduce unlinked-target sync failure and missing relink reconcile --- .../application/CohortMembershipSyncServiceTest.kt | 14 +++++--------- .../application/CohortTargetingServiceTest.kt | 6 +++++- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortMembershipSyncServiceTest.kt b/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortMembershipSyncServiceTest.kt index 96b94c47..ff2c2d23 100644 --- a/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortMembershipSyncServiceTest.kt +++ b/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortMembershipSyncServiceTest.kt @@ -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 @@ -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()) - } verify(exactly = 0) { brevoPort.addMember(any(), any()) } - verify(exactly = 0) { brevoPort.createCohort(any(), any()) } + verify(exactly = 0) { ledger.markPushed(any(), any(), any(), any()) } } @Test diff --git a/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingServiceTest.kt b/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingServiceTest.kt index 61121a69..1025d7f8 100644 --- a/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingServiceTest.kt +++ b/services/api/src/test/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingServiceTest.kt @@ -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() val cohort = mock { on { id } doReturn 7L @@ -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") } From 1306133432b4590a82a722a4426b2dfa5bba0446 Mon Sep 17 00:00:00 2001 From: Agents Agent Date: Tue, 30 Jun 2026 12:58:50 +0000 Subject: [PATCH 2/2] fix(cohort): skip membership sync until a target is linked and reconcile on link --- .../application/CohortMembershipSyncService.kt | 16 +++++++++------- .../cohort/application/CohortTargetingService.kt | 10 +++++++--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortMembershipSyncService.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortMembershipSyncService.kt index 8e14d240..8654a3f5 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortMembershipSyncService.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortMembershipSyncService.kt @@ -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. */ @@ -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) } @@ -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", -) diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingService.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingService.kt index e433fedb..4932881b 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingService.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/cohort/application/CohortTargetingService.kt @@ -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) { @@ -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