This commit is contained in:
2026-08-25 15:08:34 +08:00
parent e815abcbd7
commit 44198866d9
64 changed files with 1382 additions and 360 deletions
@@ -0,0 +1,104 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.iam.client.CustomerDimensionRuleClient;
import com.baiyue.iam.model.CustomerDimensionInitializationTask;
import com.baiyue.iam.repository.CustomerDimensionInitializationRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CustomerDimensionInitializationRelayTest {
@Mock private CustomerDimensionInitializationRepository repository;
@Mock private CustomerDimensionRuleClient client;
@InjectMocks private CustomerDimensionInitializationRelay relay;
@BeforeEach
void configure() {
ReflectionTestUtils.setField(relay, "internalToken", "internal-token");
ReflectionTestUtils.setField(relay, "maxAttempts", 12);
}
@Test
void successfulBackgroundInitializationMarksTenantReady() {
CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task);
when(client.initialize(anyString(), any())).thenReturn(ApiResponse.success(
new CustomerDimensionRuleClient.InitializationResponse(91L, true)));
relay.deliver(10001L);
verify(repository).markReady(task, workerId(), 91L);
verify(repository, never()).recordFailure(any(), anyString(), anyLong(), any(), anyString(),
anyString(), any(Boolean.class));
}
@Test
void transientFailureStaysPendingWithDiagnosticAndBackoff() {
CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task);
when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("read timeout"));
relay.deliver(10001L);
ArgumentCaptor<Boolean> exhausted = ArgumentCaptor.forClass(Boolean.class);
verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(),
anyString(), exhausted.capture());
assertFalse(exhausted.getValue());
}
@Test
void permanentRemoteRejectionBecomesFailedWithoutDefaultSuccess() {
CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task);
when(client.initialize(anyString(), any())).thenReturn(ApiResponse.failure(
ResultCode.CONFLICT, "invalid customer", "request-1", "trace-1"));
relay.deliver(10001L);
ArgumentCaptor<Boolean> exhausted = ArgumentCaptor.forClass(Boolean.class);
verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(),
anyString(), exhausted.capture());
assertTrue(exhausted.getValue());
verify(repository, never()).markReady(any(), anyString(), any());
}
@Test
void compensationIsAlsoDurableAndUsesTheStableKey() {
CustomerDimensionInitializationTask task = task("COMPENSATE", 0);
when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task);
when(client.compensate("internal-token", 10001L, "iam-customer-create:10001"))
.thenReturn(ApiResponse.success(null));
relay.deliver(10001L);
verify(repository).markCancelled(task, workerId());
}
private CustomerDimensionInitializationTask task(String action, int attempts) {
return new CustomerDimensionInitializationTask(10001L, 9001L, "iam-customer-create:10001",
action, "SENDING", attempts, 3);
}
private String workerId() {
return (String) ReflectionTestUtils.getField(relay, "workerId");
}
}
@@ -1,138 +1,61 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.client.CustomerDimensionRuleClient;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.AfterEach;
import com.baiyue.iam.repository.CustomerDimensionInitializationRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.List;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(CustomerDimensionRuleInitializationService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
@ExtendWith(MockitoExtension.class)
class CustomerDimensionRuleInitializationServiceTest {
@MockBean private CustomerDimensionRuleClient client;
@Resource private CustomerDimensionRuleInitializationService service;
@AfterEach
void clearSynchronization() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Mock private CustomerDimensionInitializationRepository repository;
@InjectMocks private CustomerDimensionRuleInitializationService service;
@Test
void retriesOneAtomicFourBoxDefaultsCallWithAStableKey() {
when(client.initialize(anyString(), any()))
.thenThrow(new RuntimeException("timeout"))
.thenReturn(ApiResponse.failure(ResultCode.SERVICE_UNAVAILABLE,
"temporarily unavailable", "request-1", "trace-1"))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false)));
void customerCreationOnlyPersistsBackgroundIntentWithStableKey() {
var result = service.initialize(10001L, 9001L);
assertFalse(result.created());
ArgumentCaptor<CustomerDimensionRuleClient.InitializationRequest> requests =
ArgumentCaptor.forClass(CustomerDimensionRuleClient.InitializationRequest.class);
verify(client, times(3)).initialize(anyString(), requests.capture());
assertEquals(List.of("iam-customer-create:10001", "iam-customer-create:10001",
"iam-customer-create:10001"),
requests.getAllValues().stream().map(
CustomerDimensionRuleClient.InitializationRequest::initializationKey).toList());
assertEquals(List.of(9001L, 9001L, 9001L), requests.getAllValues().stream()
.map(CustomerDimensionRuleClient.InitializationRequest::operatorId).toList());
assertEquals(List.of(10001L, 10001L, 10001L), requests.getAllValues().stream()
.map(CustomerDimensionRuleClient.InitializationRequest::tenantId).toList());
assertEquals("PENDING", result.status());
assertEquals("iam-customer-create:10001", result.initializationKey());
verify(repository).enqueue(10001L, 9001L, "iam-customer-create:10001");
}
@Test
void reportsServiceUnavailableWhenFourBoxDefaultsFailThreeTimesAndTriesCompensation() {
when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("timeout"));
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
BizException failure = assertThrows(BizException.class, () -> service.initialize(10001L, 9001L));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode());
verify(client, times(3)).initialize(anyString(), any());
verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001");
}
@Test
void localRollbackCompensatesFourBoxDefaultsCreatedByThisRegistration() {
when(client.initialize(anyString(), any()))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true)));
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
TransactionSynchronizationManager.initSynchronization();
var result = service.initialize(10001L, 9001L);
List<TransactionSynchronization> synchronizations =
TransactionSynchronizationManager.getSynchronizations();
TransactionSynchronizationManager.clearSynchronization();
synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
assertTrue(result.created());
verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001");
}
@Test
void localRollbackKeepsFourBoxDefaultsThatAlreadyExisted() {
when(client.initialize(anyString(), any()))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false)));
TransactionSynchronizationManager.initSynchronization();
service.initialize(10001L, 9001L);
List<TransactionSynchronization> synchronizations =
TransactionSynchronizationManager.getSynchronizations();
TransactionSynchronizationManager.clearSynchronization();
synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
verify(client, never()).compensate(anyString(), anyLong(), anyString());
}
@Test
void responseTimeoutThenIdempotentFourBoxSuccessStillCompensatesOnLocalRollback() {
when(client.initialize(anyString(), any()))
.thenThrow(new RuntimeException("response timeout"))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true)));
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
TransactionSynchronizationManager.initSynchronization();
service.initialize(10001L, 9001L);
List<TransactionSynchronization> synchronizations =
TransactionSynchronizationManager.getSynchronizations();
TransactionSynchronizationManager.clearSynchronization();
synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
verify(client, times(2)).initialize(anyString(), any());
verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001");
}
@Test
void committedClientRegistrationCanBeCompensatedWithItsDeterministicKey() {
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
void committedRegistrationCompensationUsesTheSameStableKey() {
service.compensateRegistration(10001L);
verify(repository).requestCompensation(10001L, 0L, "iam-customer-create:10001");
}
verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001");
@Test
void failedTaskCanBeManuallyRequeued() {
when(repository.manualRetry(10001L)).thenReturn(true);
service.retry(10001L);
verify(repository).manualRetry(10001L);
}
@Test
void nonFailedTaskCannotBeSilentlyRequeued() {
when(repository.manualRetry(10001L)).thenReturn(false);
BizException failure = assertThrows(BizException.class, () -> service.retry(10001L));
assertEquals(ResultCode.CONFLICT, failure.getResultCode());
}
@Test
void dimensionsDependentFlowFailsClosedUntilReady() {
BizException pending = assertThrows(BizException.class, () -> service.requireReady("PENDING"));
BizException failed = assertThrows(BizException.class, () -> service.requireReady("FAILED"));
assertEquals(ResultCode.CONFLICT, pending.getResultCode());
assertEquals(ResultCode.CONFLICT, failed.getResultCode());
service.requireReady("READY");
}
}