Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
This commit is contained in:
@@ -23,6 +23,10 @@ class IamOpenApiContractTest {
|
||||
.contains("PermissionCodesApiResponse:")
|
||||
.contains("TenantPageApiResponse:")
|
||||
.contains("TenantApiResponse:")
|
||||
.contains("PendingEnterpriseReviewListApiResponse:")
|
||||
.contains("PendingEnterpriseReviewResponse:")
|
||||
.contains("required: [id, tenantId, reviewType, status, tenantName, tenantCode, contactName, submittedAt]")
|
||||
.contains("submittedAt: {type: string, format: date-time, description: 提交审核时间}")
|
||||
.contains("TenantSuccess:")
|
||||
.contains("salesOwnerName:")
|
||||
.contains("paymentTermDays:")
|
||||
@@ -36,6 +40,11 @@ class IamOpenApiContractTest {
|
||||
.contains("TenantAddressListApiResponse:")
|
||||
.contains("TenantQualificationListApiResponse:")
|
||||
.contains("InternalUserListApiResponse:")
|
||||
.contains("MerchantMemberResponse:")
|
||||
.contains("username: {type: string, description: 统一认证登录账号}")
|
||||
.contains("lastLoginAt: {type: string, format: date-time, nullable: true")
|
||||
.contains("RoleResponse:")
|
||||
.contains("memberCount: {type: integer, format: int64, minimum: 0")
|
||||
.contains("/api/platform/v1/iam/internal-users/{membershipId}/roles:")
|
||||
.contains("AssignInternalUserRolesRequest:")
|
||||
.contains("/api/merchant/v1/iam/members/{membershipId}:")
|
||||
|
||||
@@ -40,4 +40,18 @@ class InternalCustomerExportContractTest {
|
||||
assertEquals(ResultCode.SUCCESS.code(), response.code());
|
||||
verify(service).exportMerchants(null, 100, "柏悦", "ACTIVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesFinanceCustomerMasterDataOnlyWithInternalToken() {
|
||||
BizException denied = assertThrows(BizException.class,
|
||||
() -> controller.financeCustomers(null, 1, 20, "Alice", "ACTIVE", null));
|
||||
assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
|
||||
|
||||
when(service.pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null))
|
||||
.thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
var response = controller.financeCustomers("test-internal-service-token-at-least-32-bytes", 1, 20,
|
||||
"Alice", "ACTIVE", null);
|
||||
assertEquals(ResultCode.SUCCESS.code(), response.code());
|
||||
verify(service).pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -31,7 +31,7 @@ class PlatformTenantControllerContractTest {
|
||||
assertNotNull(annotation(method, 0, Min.class));
|
||||
assertNotNull(annotation(method, 1, Min.class));
|
||||
assertEquals(200, annotation(method, 1, Max.class).value());
|
||||
assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED",
|
||||
assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED",
|
||||
annotation(method, 3, Pattern.class).regexp());
|
||||
|
||||
Set<String> statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value())
|
||||
@@ -46,6 +46,11 @@ class PlatformTenantControllerContractTest {
|
||||
|
||||
assertEquals("hasAuthority('platform:customer:review')",
|
||||
method.getAnnotation(PreAuthorize.class).value());
|
||||
ParameterizedType apiResponse = (ParameterizedType) method.getGenericReturnType();
|
||||
ParameterizedType list = (ParameterizedType) apiResponse.getActualTypeArguments()[0];
|
||||
assertEquals(java.util.List.class, list.getRawType());
|
||||
assertEquals(com.baiyue.iam.dto.TenantDtos.PendingReviewResponse.class,
|
||||
list.getActualTypeArguments()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.baiyue.iam.notification;
|
||||
|
||||
import com.baiyue.iam.notification.entity.NotificationRecipient;
|
||||
import com.baiyue.iam.notification.entity.NotificationTask;
|
||||
import com.baiyue.iam.notification.repository.NotificationRepository;
|
||||
import com.baiyue.iam.notification.service.NotificationDispatchTransactionService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class NotificationDispatchTransactionServiceTest {
|
||||
private final NotificationRepository repository = mock(NotificationRepository.class);
|
||||
private final NotificationDispatchTransactionService service = new NotificationDispatchTransactionService();
|
||||
|
||||
@BeforeEach void setup() {
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
}
|
||||
|
||||
@Test void claimDeliveryAndFailureEvidenceUseIndependentTransactions() throws Exception {
|
||||
for (var method : List.of(
|
||||
NotificationDispatchTransactionService.class.getMethod("claimScheduled", Long.class),
|
||||
NotificationDispatchTransactionService.class.getMethod("claimRetrying", Long.class),
|
||||
NotificationDispatchTransactionService.class.getMethod("deliverClaimed", Long.class),
|
||||
NotificationDispatchTransactionService.class.getMethod("recordFailure", Long.class, Throwable.class))) {
|
||||
assertThat(method.getAnnotation(Transactional.class)).isNotNull()
|
||||
.extracting(Transactional::propagation)
|
||||
.isEqualTo(Propagation.REQUIRES_NEW);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void claimStartsOneOfThreeAllowedAttempts() {
|
||||
when(repository.claimScheduled(eq(41L), eq(3), any())).thenReturn(true);
|
||||
|
||||
assertThat(service.claimScheduled(41L)).isTrue();
|
||||
|
||||
verify(repository).claimScheduled(eq(41L), eq(3), any());
|
||||
}
|
||||
|
||||
@Test void retryClaimEnforcesDelayAndSameAttemptLimit() {
|
||||
when(repository.claimRetrying(eq(41L), eq(3), any(), any())).thenReturn(true);
|
||||
|
||||
assertThat(service.claimRetrying(41L)).isTrue();
|
||||
|
||||
ArgumentCaptor<LocalDateTime> dueBefore = ArgumentCaptor.forClass(LocalDateTime.class);
|
||||
ArgumentCaptor<LocalDateTime> now = ArgumentCaptor.forClass(LocalDateTime.class);
|
||||
verify(repository).claimRetrying(eq(41L), eq(3), dueBefore.capture(), now.capture());
|
||||
assertThat(dueBefore.getValue()).isEqualTo(now.getValue().minusSeconds(30));
|
||||
}
|
||||
|
||||
@Test void successfulDeliveryWritesInboxThenCompletesTaskAndClearsRecipientFailure() {
|
||||
NotificationTask task = task("DISPATCHING");
|
||||
NotificationRecipient recipient = recipient("FAILED");
|
||||
when(repository.task(41L)).thenReturn(task);
|
||||
when(repository.recipients(41L)).thenReturn(List.of(recipient));
|
||||
when(repository.markRecipientDelivered(eq(51L), any())).thenReturn(true);
|
||||
when(repository.completeDispatch(eq(41L), any())).thenReturn(true);
|
||||
|
||||
service.deliverClaimed(41L);
|
||||
|
||||
verify(repository).insertInbox(argThat(item -> item.getTaskId().equals(41L)
|
||||
&& item.getRecipientId().equals(51L) && "UNREAD".equals(item.getStatus())));
|
||||
var ordered = inOrder(repository);
|
||||
ordered.verify(repository).markRecipientDelivered(eq(51L), any());
|
||||
ordered.verify(repository).completeDispatch(eq(41L), any());
|
||||
}
|
||||
|
||||
@Test void alreadyDeliveredRecipientIsNotInsertedAgain() {
|
||||
NotificationTask task = task("DISPATCHING");
|
||||
NotificationRecipient recipient = recipient("DELIVERED");
|
||||
when(repository.task(41L)).thenReturn(task);
|
||||
when(repository.recipients(41L)).thenReturn(List.of(recipient));
|
||||
when(repository.completeDispatch(eq(41L), any())).thenReturn(true);
|
||||
|
||||
service.deliverClaimed(41L);
|
||||
|
||||
verify(repository, never()).insertInbox(any());
|
||||
verify(repository, never()).markRecipientDelivered(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test void failurePersistsBoundedEvidenceAndUsesAttemptLimit() {
|
||||
when(repository.failDispatch(eq(41L), eq(3), anyString(), any())).thenReturn(true);
|
||||
|
||||
service.recordFailure(41L, new IllegalStateException("x".repeat(1200)));
|
||||
|
||||
ArgumentCaptor<String> error = ArgumentCaptor.forClass(String.class);
|
||||
verify(repository).markUndeliveredRecipientsFailed(eq(41L), error.capture(), any());
|
||||
assertThat(error.getValue()).hasSize(1000);
|
||||
verify(repository).failDispatch(eq(41L), eq(3), eq(error.getValue()), any());
|
||||
}
|
||||
|
||||
@Test void rejectsDeliveryWithoutCurrentClaim() {
|
||||
when(repository.task(41L)).thenReturn(task("RETRYING"));
|
||||
|
||||
assertThatThrownBy(() -> service.deliverClaimed(41L))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("NOTIFICATION_DISPATCH_CLAIM_LOST");
|
||||
|
||||
verify(repository, never()).insertInbox(any());
|
||||
}
|
||||
|
||||
private NotificationTask task(String status) {
|
||||
NotificationTask task = new NotificationTask();
|
||||
task.setId(41L); task.setStatus(status); task.setCategory("ORDER"); task.setTitle("状态变化");
|
||||
task.setBody("正文"); task.setSourceEventId("event-1"); task.setSourceEventType("order.status.changed");
|
||||
return task;
|
||||
}
|
||||
|
||||
private NotificationRecipient recipient(String status) {
|
||||
NotificationRecipient recipient = new NotificationRecipient();
|
||||
recipient.setId(51L); recipient.setTaskId(41L); recipient.setTenantId(9L);
|
||||
recipient.setMembershipId(11L); recipient.setAppCode("MERCHANT"); recipient.setStatus(status);
|
||||
recipient.setFailureReason("old error"); recipient.setUpdatedAt(LocalDateTime.now());
|
||||
return recipient;
|
||||
}
|
||||
}
|
||||
+10
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.iam.notification;
|
||||
|
||||
import com.baiyue.iam.notification.controller.NotificationInboxController;
|
||||
import com.baiyue.iam.notification.controller.PlatformNotificationController;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
@@ -18,6 +19,15 @@ class NotificationInboxPermissionContractTest {
|
||||
assertPermission("readAll", "hasAuthority(#app + ':notification:read-all')");
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskDetailUsesTheTaskListPermission() {
|
||||
var method = Arrays.stream(PlatformNotificationController.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals("taskDetail"))
|
||||
.findFirst().orElseThrow();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:notification:task:list')");
|
||||
}
|
||||
|
||||
private static void assertPermission(String methodName, String expected) {
|
||||
var method = Arrays.stream(NotificationInboxController.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(methodName))
|
||||
|
||||
@@ -28,6 +28,7 @@ class NotificationOpenApiContractTest {
|
||||
"/{app}/v1/notifications/read-all", "/platform/v1/notifications",
|
||||
"/platform/v1/notification-templates", "/platform/v1/notification-templates/{id}",
|
||||
"/platform/v1/notification-templates/{id}/status", "/platform/v1/notification-tasks",
|
||||
"/platform/v1/notification-tasks/{id}",
|
||||
"/platform/v1/notification-tasks/{id}/cancel", "/platform/v1/notification-tasks/{id}/retry");
|
||||
assertThat(paths).doesNotContainKey("/merchant/v1/notifications:create");
|
||||
|
||||
@@ -37,6 +38,7 @@ class NotificationOpenApiContractTest {
|
||||
assertRequestBody(paths, "/platform/v1/notification-templates/{id}/status", "put");
|
||||
for (String path : List.of("/platform/v1/notification-templates/{id}",
|
||||
"/platform/v1/notification-templates/{id}/status",
|
||||
"/platform/v1/notification-tasks/{id}",
|
||||
"/platform/v1/notification-tasks/{id}/cancel",
|
||||
"/platform/v1/notification-tasks/{id}/retry")) {
|
||||
Map<String, Object> operation = map(map(paths.get(path)).values().iterator().next());
|
||||
@@ -50,6 +52,7 @@ class NotificationOpenApiContractTest {
|
||||
"UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxPageResponse",
|
||||
"InboxDetailResponse", "UnreadCountResponse", "ReadAllResponse",
|
||||
"TemplateItem", "TemplateDetailResponse", "TaskItem", "TaskDetailResponse");
|
||||
assertThat(schemas).containsKeys("TaskItemResponse", "TaskRecipientItem", "TaskDetail");
|
||||
for (String schema : List.of("CreateNotificationRequest", "CreateTemplateRequest",
|
||||
"UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxItem", "TemplateItem",
|
||||
"TaskItem", "InboxPageResponse", "TemplateDetailResponse", "TaskDetailResponse")) {
|
||||
@@ -59,6 +62,13 @@ class NotificationOpenApiContractTest {
|
||||
.contains("DISABLED cannot return to ACTIVE")
|
||||
.contains("does not support deleting or clearing read messages")
|
||||
.contains("name: taskNo").contains("name: templateCode").contains("name: title");
|
||||
Map<String, Object> inboxItem = map(schemas.get("InboxItem"));
|
||||
assertThat(list(inboxItem.get("required"))).contains("summary");
|
||||
assertThat(map(map(inboxItem.get("properties")).get("summary"))).containsEntry("maxLength", 120);
|
||||
Map<String, Object> taskDetail = map(schemas.get("TaskDetail"));
|
||||
assertThat(map(map(taskDetail.get("properties")).get("attemptCount")))
|
||||
.containsEntry("minimum", 0).containsEntry("maximum", 3);
|
||||
assertThat(document).contains("scheduler delivery starts after the retry delay");
|
||||
}
|
||||
|
||||
private void assertRequestBody(Map<String, Object> paths, String path, String method) {
|
||||
|
||||
+36
-2
@@ -13,15 +13,34 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class NotificationSchedulerRecoveryTest {
|
||||
@Test void claimSqlAtomicallyIncrementsAttemptsAndEnforcesDueTimeAndLimit() throws Exception {
|
||||
Update scheduled = NotificationTaskMapper.class
|
||||
.getMethod("claimScheduled", Long.class, int.class, java.time.LocalDateTime.class)
|
||||
.getAnnotation(Update.class);
|
||||
String scheduledSql = String.join(" ", scheduled.value()).replaceAll("\\s+", " ").toUpperCase();
|
||||
assertThat(scheduledSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1",
|
||||
"STATUS = 'SCHEDULED'", "SCHEDULED_AT <= #{NOW}", "ATTEMPT_COUNT < #{MAXATTEMPTS}");
|
||||
|
||||
Update retrying = NotificationTaskMapper.class
|
||||
.getMethod("claimRetrying", Long.class, int.class, java.time.LocalDateTime.class,
|
||||
java.time.LocalDateTime.class)
|
||||
.getAnnotation(Update.class);
|
||||
String retryingSql = String.join(" ", retrying.value()).replaceAll("\\s+", " ").toUpperCase();
|
||||
assertThat(retryingSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1",
|
||||
"STATUS = 'RETRYING'", "UPDATED_AT <= #{DUEBEFORE}", "ATTEMPT_COUNT < #{MAXATTEMPTS}");
|
||||
}
|
||||
|
||||
@Test void recoverySqlRequiresTimeoutAndNoSuccessfulDeliveryEvidence() throws Exception {
|
||||
Update retry = NotificationTaskMapper.class
|
||||
.getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class)
|
||||
.getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class,
|
||||
java.time.LocalDateTime.class, int.class)
|
||||
.getAnnotation(Update.class);
|
||||
String retrySql = String.join(" ", retry.value()).replaceAll("\\s+", " ").toUpperCase();
|
||||
|
||||
assertThat(retrySql).contains("TASK.STATUS = 'DISPATCHING'", "TASK.UPDATED_AT <= #{STALEBEFORE}",
|
||||
"TASK.SENT_AT IS NULL", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX")
|
||||
.contains("SET TASK.STATUS = 'RETRYING'");
|
||||
.contains("SET TASK.STATUS = 'RETRYING'", "TASK.ATTEMPT_COUNT < #{MAXATTEMPTS}")
|
||||
.doesNotContain("ATTEMPT_COUNT = TASK.ATTEMPT_COUNT + 1");
|
||||
|
||||
Update dead = NotificationTaskMapper.class
|
||||
.getMethod("deadTimedOutDispatchingWithDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class)
|
||||
@@ -30,6 +49,21 @@ class NotificationSchedulerRecoveryTest {
|
||||
assertThat(deadSql).contains("SET TASK.STATUS = 'DEAD'", "TASK.STATUS = 'DISPATCHING'",
|
||||
"TASK.UPDATED_AT <= #{STALEBEFORE}", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX",
|
||||
"DISPATCH_TIMEOUT_WITH_DELIVERY_EVIDENCE");
|
||||
|
||||
Update attemptLimit = NotificationTaskMapper.class
|
||||
.getMethod("deadTimedOutDispatchingAtAttemptLimit", java.time.LocalDateTime.class,
|
||||
java.time.LocalDateTime.class, int.class)
|
||||
.getAnnotation(Update.class);
|
||||
String attemptLimitSql = String.join(" ", attemptLimit.value()).replaceAll("\\s+", " ").toUpperCase();
|
||||
assertThat(attemptLimitSql).contains("SET TASK.STATUS = 'DEAD'",
|
||||
"TASK.ATTEMPT_COUNT >= #{MAXATTEMPTS}", "DISPATCH_TIMEOUT_ATTEMPT_LIMIT");
|
||||
|
||||
Update failed = NotificationTaskMapper.class
|
||||
.getMethod("failDispatch", Long.class, int.class, String.class, java.time.LocalDateTime.class)
|
||||
.getAnnotation(Update.class);
|
||||
String failedSql = String.join(" ", failed.value()).replaceAll("\\s+", " ").toUpperCase();
|
||||
assertThat(failedSql).contains("STATUS = CASE WHEN ATTEMPT_COUNT >= #{MAXATTEMPTS} THEN 'DEAD' ELSE 'RETRYING' END",
|
||||
"LAST_ERROR = #{ERROR}", "STATUS = 'DISPATCHING'");
|
||||
}
|
||||
|
||||
@Test void schedulerRecoversBeforeClaimingScheduledAndRetryingTasks() {
|
||||
|
||||
@@ -4,10 +4,12 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.iam.notification.dto.NotificationDtos.*;
|
||||
import com.baiyue.iam.notification.entity.NotificationRecipient;
|
||||
import com.baiyue.iam.notification.entity.NotificationEventInbox;
|
||||
import com.baiyue.iam.notification.entity.NotificationInbox;
|
||||
import com.baiyue.iam.notification.entity.NotificationTask;
|
||||
import com.baiyue.iam.notification.entity.NotificationTemplate;
|
||||
import com.baiyue.iam.notification.repository.NotificationRepository;
|
||||
import com.baiyue.iam.notification.service.NotificationRecipientResolver;
|
||||
import com.baiyue.iam.notification.service.NotificationDispatchTransactionService;
|
||||
import com.baiyue.iam.notification.service.NotificationService;
|
||||
import com.baiyue.iam.notification.service.NotificationTemplateRenderer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -27,6 +29,8 @@ import static org.mockito.Mockito.*;
|
||||
class NotificationServiceTest {
|
||||
private final NotificationRepository repository = mock(NotificationRepository.class);
|
||||
private final NotificationRecipientResolver resolver = mock(NotificationRecipientResolver.class);
|
||||
private final NotificationDispatchTransactionService dispatchTransactions =
|
||||
mock(NotificationDispatchTransactionService.class);
|
||||
private final NotificationService service = new NotificationService();
|
||||
|
||||
@BeforeEach void setup() {
|
||||
@@ -34,6 +38,7 @@ class NotificationServiceTest {
|
||||
ReflectionTestUtils.setField(service, "renderer", new NotificationTemplateRenderer());
|
||||
ReflectionTestUtils.setField(service, "resolver", resolver);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||
ReflectionTestUtils.setField(service, "dispatchTransactions", dispatchTransactions);
|
||||
}
|
||||
|
||||
@Test void createsTaskAndImmutableRecipientSnapshot() {
|
||||
@@ -63,6 +68,39 @@ class NotificationServiceTest {
|
||||
assertThat(stored.get(0).getStatus()).isEqualTo("DELIVERED");
|
||||
}
|
||||
|
||||
@Test void returnsWhitespaceCompactedUnicodeBoundedInboxSummaryWithoutFullBody() {
|
||||
NotificationInbox item = new NotificationInbox();
|
||||
item.setId(8L); item.setCategory("ORDER"); item.setTitle("状态变化"); item.setStatus("UNREAD");
|
||||
item.setSentAt(LocalDateTime.now()); item.setBody(" 第一行\n" + "测".repeat(140) + " ");
|
||||
when(repository.pageInbox(9L, 11L, "MERCHANT", 1, 20, null, null))
|
||||
.thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(item), 1, 20, 1, 1));
|
||||
|
||||
NotificationSummary summary = service.inbox(9L, 11L, "MERCHANT", 1, 20, null, null).records().get(0);
|
||||
|
||||
assertThat(summary.summary()).startsWith("第一行 ");
|
||||
assertThat(summary.summary().codePointCount(0, summary.summary().length())).isEqualTo(120);
|
||||
assertThat(summary.summary()).isNotEqualTo(item.getBody());
|
||||
}
|
||||
|
||||
@Test void returnsTaskAndRecipientFailureEvidence() {
|
||||
NotificationTask task = task(41L, "DEAD");
|
||||
task.setTaskNo("NT41"); task.setLastError("gateway timeout"); task.setAttemptCount(3);
|
||||
NotificationRecipient recipient = new NotificationRecipient();
|
||||
recipient.setId(51L); recipient.setDisplayName("客户成员"); recipient.setAppCode("MERCHANT");
|
||||
recipient.setStatus("FAILED"); recipient.setFailureReason("gateway timeout");
|
||||
when(repository.task(41L)).thenReturn(task);
|
||||
when(repository.recipients(41L)).thenReturn(List.of(recipient));
|
||||
|
||||
TaskDetail detail = service.taskDetail(41L);
|
||||
|
||||
assertThat(detail.attemptCount()).isEqualTo(3);
|
||||
assertThat(detail.lastError()).isEqualTo("gateway timeout");
|
||||
assertThat(detail.recipients()).singleElement().satisfies(value -> {
|
||||
assertThat(value.status()).isEqualTo("FAILED");
|
||||
assertThat(value.failureReason()).isEqualTo("gateway timeout");
|
||||
});
|
||||
}
|
||||
|
||||
@Test void returnsOriginalTaskForRepeatedIdempotencyKey() {
|
||||
NotificationTask existing = new NotificationTask(); existing.setId(5L); existing.setTaskNo("NT5");
|
||||
existing.setCategory("ORDER"); existing.setTitle("A"); existing.setStatus("SENT"); existing.setRecipientCount(1);
|
||||
@@ -151,31 +189,28 @@ class NotificationServiceTest {
|
||||
}
|
||||
|
||||
@Test void recoversOnlyTasksOlderThanConfiguredDispatchTimeout() {
|
||||
when(repository.recoverTimedOutDispatching(any(), any())).thenReturn(2);
|
||||
when(repository.recoverTimedOutDispatching(any(), any(), eq(3))).thenReturn(2);
|
||||
|
||||
assertThat(service.recoverTimedOutDispatching(300)).isEqualTo(2);
|
||||
|
||||
ArgumentCaptor<LocalDateTime> staleBefore = ArgumentCaptor.forClass(LocalDateTime.class);
|
||||
ArgumentCaptor<LocalDateTime> now = ArgumentCaptor.forClass(LocalDateTime.class);
|
||||
verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture());
|
||||
verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture(), eq(3));
|
||||
assertThat(staleBefore.getValue()).isEqualTo(now.getValue().minusSeconds(300));
|
||||
}
|
||||
|
||||
@Test void onlyOneInstanceCanClaimRecoveredTask() {
|
||||
when(repository.claimRetrying(41L)).thenReturn(true, false);
|
||||
NotificationTask task = task(41L, "RETRYING");
|
||||
when(repository.task(41L)).thenReturn(task);
|
||||
when(repository.recipients(41L)).thenReturn(List.of());
|
||||
when(dispatchTransactions.claimRetrying(41L)).thenReturn(true, false);
|
||||
|
||||
assertThat(service.dispatchRetrying(41L)).isTrue();
|
||||
assertThat(service.dispatchRetrying(41L)).isFalse();
|
||||
|
||||
verify(repository, times(2)).claimRetrying(41L);
|
||||
verify(repository, times(1)).saveTask(task);
|
||||
verify(dispatchTransactions, times(2)).claimRetrying(41L);
|
||||
verify(dispatchTransactions, times(1)).deliverClaimed(41L);
|
||||
}
|
||||
|
||||
@Test void sentTaskCannotBeClaimedForRecoveryDelivery() {
|
||||
when(repository.claimRetrying(42L)).thenReturn(false);
|
||||
when(dispatchTransactions.claimRetrying(42L)).thenReturn(false);
|
||||
|
||||
assertThat(service.dispatchRetrying(42L)).isFalse();
|
||||
|
||||
@@ -183,6 +218,30 @@ class NotificationServiceTest {
|
||||
verify(repository, never()).recipients(42L);
|
||||
}
|
||||
|
||||
@Test void persistsScheduledDeliveryFailureAfterCommittedClaim() {
|
||||
IllegalStateException failure = new IllegalStateException("inbox unavailable");
|
||||
when(dispatchTransactions.claimScheduled(43L)).thenReturn(true);
|
||||
doThrow(failure).when(dispatchTransactions).deliverClaimed(43L);
|
||||
|
||||
assertThatThrownBy(() -> service.dispatchScheduled(43L)).isSameAs(failure);
|
||||
|
||||
verify(dispatchTransactions).recordFailure(43L, failure);
|
||||
}
|
||||
|
||||
@Test void manualRetryResetsAttemptBudgetAndWaitsForScheduler() {
|
||||
NotificationTask task = task(44L, "DEAD");
|
||||
task.setAttemptCount(3); task.setLastError("timeout");
|
||||
when(repository.task(44L)).thenReturn(task);
|
||||
when(repository.requeueForManualRetry(eq(44L), any())).thenReturn(true);
|
||||
|
||||
TaskListItem result = service.retry(44L);
|
||||
|
||||
assertThat(result.status()).isEqualTo("RETRYING");
|
||||
assertThat(task.getAttemptCount()).isZero();
|
||||
assertThat(task.getLastError()).isNull();
|
||||
verify(dispatchTransactions, never()).deliverClaimed(anyLong());
|
||||
}
|
||||
|
||||
private NotificationTask task(long id, String status) {
|
||||
NotificationTask value = new NotificationTask();
|
||||
value.setId(id); value.setStatus(status); value.setCategory("ORDER"); value.setTitle("A");
|
||||
|
||||
+26
-1
@@ -13,7 +13,12 @@ class AdminPrototypeMenuPermissionSqlContractTest {
|
||||
private static final String[] FINANCE_MENUS = {
|
||||
"platform:finance:customer-account:list", "platform:finance:customer-arap:list",
|
||||
"platform:finance:customer-statement:list", "platform:finance:settlement-ar:list",
|
||||
"platform:finance:settlement-ap:list"
|
||||
"platform:finance:settlement-ap:list", "platform:finance:receivable-statistics:list"
|
||||
};
|
||||
private static final String[] FINANCE_EXPORTS = {
|
||||
"platform:finance:customer-account:export", "platform:finance:customer-arap:export",
|
||||
"platform:finance:customer-statement:export", "platform:finance:settlement-ar:export",
|
||||
"platform:finance:settlement-ap:export", "platform:finance:receivable-statistics:export"
|
||||
};
|
||||
|
||||
@Test
|
||||
@@ -23,9 +28,11 @@ class AdminPrototypeMenuPermissionSqlContractTest {
|
||||
|
||||
for (int index = 0; index < FINANCE_MENUS.length; index++) {
|
||||
assertThat(sql).contains("(" + (5105040 + index) + ",0,'PLATFORM','MENU',5105000,'" + FINANCE_MENUS[index] + "'");
|
||||
assertThat(sql).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'");
|
||||
}
|
||||
assertThat(sql)
|
||||
.contains("'platform:finance:fee-source:review' code, 5105041 parent_id")
|
||||
.contains("'platform:finance:fee-source:edit', 5105041")
|
||||
.contains("'platform:finance:reconciliation:create', 5105042")
|
||||
.contains("'platform:finance:settlement-ar:create', 5105043")
|
||||
.contains("'platform:finance:settlement-ap:create', 5105044")
|
||||
@@ -36,6 +43,24 @@ class AdminPrototypeMenuPermissionSqlContractTest {
|
||||
.doesNotContain("SELECT 'platform:finance:settlement-ar:view', 5105043")
|
||||
.doesNotContain("SELECT 'platform:finance:settlement-ap:view', 5105044")
|
||||
.contains("role_code='PLATFORM_ADMIN'");
|
||||
|
||||
int grantStart = sql.indexOf("role_code='PLATFORM_ADMIN'");
|
||||
String platformAdminGrant = sql.substring(grantStart, sql.indexOf(");", grantStart));
|
||||
for (String permission : FINANCE_EXPORTS) assertThat(platformAdminGrant).contains(permission);
|
||||
assertThat(platformAdminGrant).contains("platform:download:list");
|
||||
|
||||
String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
|
||||
for (int index = 0; index < FINANCE_EXPORTS.length; index++) {
|
||||
assertThat(unified).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'");
|
||||
}
|
||||
assertThat(unified)
|
||||
.contains("platform:finance:receivable-statistics:export")
|
||||
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32")
|
||||
.contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33")
|
||||
.contains("Finance 导出和下载中心授权不完整")
|
||||
.contains("merchant:finance:statement:confirm")
|
||||
.satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'"))
|
||||
.isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()")));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -40,8 +40,8 @@ class CustomerListExportProviderRepositoryTest {
|
||||
assertThat(query.getSqlSegment())
|
||||
.contains("tenant_type")
|
||||
.contains("status")
|
||||
.contains("tenant_code")
|
||||
.contains("tenant_name")
|
||||
.doesNotContain("tenant_code")
|
||||
.contains("id >")
|
||||
.contains("id <=")
|
||||
.contains("ORDER BY id ASC")
|
||||
|
||||
+5
-5
@@ -17,7 +17,7 @@ class FinanceG1G2PermissionSqlContractTest {
|
||||
|
||||
assertThat(sql)
|
||||
.contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'")
|
||||
.contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement'")
|
||||
.contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit'")
|
||||
.contains("NULL, 'POST', 150, 'ACTIVE', 1, 0")
|
||||
.contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0")
|
||||
.contains("ON DUPLICATE KEY UPDATE")
|
||||
@@ -35,7 +35,7 @@ class FinanceG1G2PermissionSqlContractTest {
|
||||
|
||||
assertThat(sql)
|
||||
.contains("SIGNAL SQLSTATE '45000'")
|
||||
.contains("permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025")
|
||||
.contains("permission_code = 'platform:finance:fee-source:edit' AND id <> 5105025")
|
||||
.contains("tenant_id <> 0 OR app_code <> 'PLATFORM'")
|
||||
.contains("parent_id = 5105000")
|
||||
.contains("permission_type = 'MENU'")
|
||||
@@ -52,7 +52,7 @@ class FinanceG1G2PermissionSqlContractTest {
|
||||
|
||||
assertThat(financeSection)
|
||||
.contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'")
|
||||
.contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement'")
|
||||
.contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit'")
|
||||
.contains("NULL, 'POST', 150, 'ACTIVE', 1, 0")
|
||||
.contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0")
|
||||
.contains("permission_code IN (\n 'platform:finance',")
|
||||
@@ -63,9 +63,9 @@ class FinanceG1G2PermissionSqlContractTest {
|
||||
assertThat(sql)
|
||||
.contains("platform:finance:settlement-ar:list")
|
||||
.contains("platform:finance:settlement-ap:list")
|
||||
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27")
|
||||
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32")
|
||||
.contains("rp.role_id=4000001")
|
||||
.contains("LIKE 'platform:finance:%')) <> 28")
|
||||
.contains("LIKE 'platform:finance:%')) <> 33")
|
||||
.contains("平台管理员 Finance 授权不完整");
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -51,9 +51,9 @@ class FinanceG3G4PermissionSqlContractTest {
|
||||
assertThat(sql)
|
||||
.contains("platform:finance:settlement-ar:list")
|
||||
.contains("platform:finance:settlement-ap:list")
|
||||
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27")
|
||||
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32")
|
||||
.contains("rp.role_id=4000001")
|
||||
.contains("LIKE 'platform:finance:%')) <> 28");
|
||||
.contains("LIKE 'platform:finance:%')) <> 33");
|
||||
}
|
||||
|
||||
private static Path resolveUnifiedSql() {
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -45,15 +46,20 @@ class MemberServiceTest {
|
||||
when(repository.findByTenantAndId(100L, 20L)).thenReturn(member);
|
||||
when(repository.findUser(30L)).thenReturn(user);
|
||||
when(repository.findRoleIds(100L, 20L)).thenReturn(List.of(50L));
|
||||
when(auth.summary("internal-token", 40L)).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(40L, "member01", "测试成员", "ACTIVE",
|
||||
LocalDateTime.of(2026, 8, 17, 10, 30), 100L, 20L, "MERCHANT")));
|
||||
}
|
||||
|
||||
@Test void shouldHandleCase01() {
|
||||
user.setPhone("13800138000"); user.setEmail("member@example.com");
|
||||
MemberResponse result = service.detail(100L, 20L);
|
||||
assertEquals("测试成员", result.displayName());
|
||||
assertEquals("member01", result.username());
|
||||
assertEquals("13800138000", result.phone());
|
||||
assertEquals(LocalDateTime.of(2026, 8, 17, 10, 30), result.lastLoginAt());
|
||||
assertEquals(List.of(50L), result.roleIds());
|
||||
verifyNoInteractions(auth);
|
||||
verify(auth).summary("internal-token", 40L);
|
||||
}
|
||||
|
||||
@Test void shouldReturnOwnerAccountForPlatformCustomerDetail() {
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.springframework.test.context.TestPropertySource;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -47,11 +48,14 @@ class RbacServiceTest {
|
||||
IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM");
|
||||
when(repository.findRoles(100L, "MERCHANT")).thenReturn(List.of(role));
|
||||
when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L, 22L));
|
||||
when(repository.findRoleMemberCounts(100L, List.of(11L))).thenReturn(Map.of(11L, 3L));
|
||||
|
||||
var result = service.roles(100L, "MERCHANT");
|
||||
|
||||
assertEquals(Set.of(21L, 22L), result.get(0).permissionIds());
|
||||
assertEquals(3L, result.get(0).memberCount());
|
||||
assertEquals(100L, result.get(0).tenantId());
|
||||
verify(repository, never()).countRoleMembers(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -30,6 +30,7 @@ import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -94,6 +95,55 @@ class TenantServiceTest {
|
||||
verify(repository).pageMerchants(1, 10, "Example", "ACTIVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingReviewsIncludeCustomerSummaryWithoutPerReviewQueries() {
|
||||
IamTenantReview review = review(71L, 10001L);
|
||||
review.setReviewType("REGISTER");
|
||||
review.setCreatedAt(LocalDateTime.of(2026, 8, 17, 10, 27));
|
||||
IamTenant tenant = merchant(10001L, "PENDING");
|
||||
tenant.setTenantCode("TEN10001");
|
||||
tenant.setTenantName("Example Merchant");
|
||||
tenant.setContactName("Alice");
|
||||
tenant.setContactPhone("13800138000");
|
||||
tenant.setContactEmail("alice@example.com");
|
||||
when(repository.findPendingReviews()).thenReturn(List.of(review));
|
||||
when(repository.findMerchantsByIds(Set.of(10001L))).thenReturn(List.of(tenant));
|
||||
|
||||
var result = service.pendingReviews();
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(71L, result.get(0).id());
|
||||
assertEquals(10001L, result.get(0).tenantId());
|
||||
assertEquals("REGISTER", result.get(0).reviewType());
|
||||
assertEquals("PENDING", result.get(0).status());
|
||||
assertEquals("Example Merchant", result.get(0).tenantName());
|
||||
assertEquals("TEN10001", result.get(0).tenantCode());
|
||||
assertEquals("Alice", result.get(0).contactName());
|
||||
assertEquals("13800138000", result.get(0).contactPhone());
|
||||
assertEquals("alice@example.com", result.get(0).contactEmail());
|
||||
assertEquals(LocalDateTime.of(2026, 8, 17, 10, 27), result.get(0).submittedAt());
|
||||
verify(repository).findMerchantsByIds(Set.of(10001L));
|
||||
verify(repository, never()).findTenant(10001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageFinanceCustomersKeepsFullContactSummaryForInternalFinance() {
|
||||
IamTenant tenant = merchant(10001L, "ACTIVE");
|
||||
tenant.setTenantCode("TEN10001");
|
||||
tenant.setTenantName("Example Merchant");
|
||||
tenant.setContactName("Alice");
|
||||
tenant.setContactPhone("13800138000");
|
||||
when(repository.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null))
|
||||
.thenReturn(new PageResult<>(List.of(tenant), 1, 10, 1, 1));
|
||||
|
||||
var result = service.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null);
|
||||
|
||||
assertEquals("TEN10001", result.records().get(0).tenantCode());
|
||||
assertEquals("Alice", result.records().get(0).contactName());
|
||||
assertEquals("13800138000", result.records().get(0).contactPhone());
|
||||
verify(repository).pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformCanMaintainAndDisableOnlyMerchantTenant() {
|
||||
IamTenant tenant = merchant(10001L, "ACTIVE");
|
||||
@@ -162,11 +212,11 @@ class TenantServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void archiveIsIdempotentForDisabledCustomerWithoutCallingAuth() {
|
||||
IamTenant tenant = merchant(10001L, "DISABLED");
|
||||
void archiveIsIdempotentForArchivedCustomerWithoutCallingAuth() {
|
||||
IamTenant tenant = merchant(10001L, "ARCHIVED");
|
||||
when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
|
||||
|
||||
assertEquals("DISABLED", service.archiveMerchant(10001L).status());
|
||||
assertEquals("ARCHIVED", service.archiveMerchant(10001L).status());
|
||||
verify(authClient, never()).archiveTenant(any(), any());
|
||||
verify(repository, never()).saveTenant(any());
|
||||
}
|
||||
@@ -205,6 +255,34 @@ class TenantServiceTest {
|
||||
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void archivedCustomerCannotBeReactivated() {
|
||||
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ARCHIVED"));
|
||||
|
||||
BizException exception = assertThrows(BizException.class,
|
||||
() -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("ACTIVE")));
|
||||
|
||||
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
|
||||
verify(repository, never()).saveTenant(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void archivedCustomerIsReadOnlyForPlatformAndMerchantProfileUpdates() {
|
||||
IamTenant archived = merchant(10001L, "ARCHIVED");
|
||||
when(repository.findTenant(10001L)).thenReturn(archived);
|
||||
|
||||
BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L,
|
||||
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null,
|
||||
"基础版", null, null, 30, null, null, null, "CNY", null, null)));
|
||||
BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L,
|
||||
new UpdateProfileRequest("New name", "New legal", "Bob", "Alice",
|
||||
"13800138000", "alice@example.com")));
|
||||
|
||||
assertEquals(ResultCode.CONFLICT, platformFailure.getResultCode());
|
||||
assertEquals(ResultCode.CONFLICT, merchantFailure.getResultCode());
|
||||
verify(repository, never()).saveTenant(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantProfileCannotReadOrUpdatePlatformTenant() {
|
||||
IamTenant platform = new IamTenant();
|
||||
@@ -283,7 +361,7 @@ class TenantServiceTest {
|
||||
|
||||
var archived = service.archiveMerchant(10001L);
|
||||
|
||||
assertEquals("DISABLED", archived.status());
|
||||
assertEquals("ARCHIVED", archived.status());
|
||||
verify(authClient).archiveTenant("internal-token", 10001L);
|
||||
verify(repository).saveTenant(tenant);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user