This commit is contained in:
2026-08-12 19:30:14 +08:00
parent e7dfd2790d
commit db86e97c0d
12 changed files with 361 additions and 18 deletions
@@ -0,0 +1,109 @@
package com.baiyue.iam.notification;
import com.baiyue.iam.notification.dto.NotificationDtos.CreateNotificationRequest;
import com.baiyue.iam.notification.dto.NotificationDtos.TaskListItem;
import com.baiyue.iam.notification.entity.NotificationEventInbox;
import com.baiyue.iam.notification.mq.NotificationBusinessEventConsumer;
import com.baiyue.iam.notification.service.NotificationService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Map;
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 NotificationBusinessEventConsumerTest {
private final NotificationService service = mock(NotificationService.class);
private final NotificationBusinessEventConsumer consumer = new NotificationBusinessEventConsumer();
@BeforeEach void setup() {
ReflectionTestUtils.setField(consumer, "service", service);
ReflectionTestUtils.setField(consumer, "objectMapper", new ObjectMapper());
}
@Test void createsTaskWithEventTypeAndCompletesAudit() {
NotificationEventInbox event = event();
var claim = new NotificationService.EventClaim(event, NotificationService.EventClaimStatus.ACQUIRED);
when(service.beginBusinessEvent(eq("source-1"), eq("order.status.changed"), anyString())).thenReturn(claim);
when(service.create(any(), eq(0L))).thenReturn(new TaskListItem(31L, "NT31", "ORDER", "A", "SENT", 1, null, null, false));
consumer.onMessage(message("READY"));
ArgumentCaptor<CreateNotificationRequest> request = ArgumentCaptor.forClass(CreateNotificationRequest.class);
verify(service).create(request.capture(), eq(0L));
assertThat(request.getValue().sourceEventId()).isEqualTo("source-1");
assertThat(request.getValue().sourceEventType()).isEqualTo("order.status.changed");
verify(service).completeBusinessEvent(claim, 31L);
}
@Test void processedDuplicateIsAcknowledgedWithoutCreatingTask() {
var claim = new NotificationService.EventClaim(event(), NotificationService.EventClaimStatus.PROCESSED);
when(service.beginBusinessEvent(eq("source-1"), eq("order.status.changed"), anyString())).thenReturn(claim);
consumer.onMessage(message("READY"));
verify(service, never()).create(any(), anyLong());
verify(service, never()).completeBusinessEvent(any(), anyLong());
}
@Test void processingDuplicateIsRetriedWithoutOverwritingOwnerAudit() {
var claim = new NotificationService.EventClaim(event(), NotificationService.EventClaimStatus.PROCESSING);
when(service.beginBusinessEvent(eq("source-1"), eq("order.status.changed"), anyString())).thenReturn(claim);
assertThatThrownBy(() -> consumer.onMessage(message("READY")))
.isInstanceOf(IllegalStateException.class).hasMessageContaining("NOTIFICATION_EVENT_PROCESSING");
verify(service, never()).failBusinessEvent(any(), any());
}
@Test void taskFailureIsRecordedAndRethrownForRocketMqRetry() {
var claim = new NotificationService.EventClaim(event(), NotificationService.EventClaimStatus.ACQUIRED);
when(service.beginBusinessEvent(eq("source-1"), eq("order.status.changed"), anyString())).thenReturn(claim);
when(service.create(any(), eq(0L))).thenThrow(new IllegalStateException("delivery failed"));
assertThatThrownBy(() -> consumer.onMessage(message("READY")))
.isInstanceOf(IllegalStateException.class).hasMessageContaining("delivery failed");
verify(service).failBusinessEvent(eq(claim), argThat(error -> "delivery failed".equals(error.getMessage())));
}
@Test void payloadHashIsStableAcrossMapKeyOrder() {
var processed = new NotificationService.EventClaim(event(), NotificationService.EventClaimStatus.PROCESSED);
when(service.beginBusinessEvent(eq("source-1"), eq("order.status.changed"), anyString())).thenReturn(processed);
consumer.onMessage(message("READY"));
String first = hashArgument();
reset(service);
when(service.beginBusinessEvent(eq("source-1"), eq("order.status.changed"), anyString())).thenReturn(processed);
consumer.onMessage("{\"sourceEventId\":\"source-1\",\"tenantId\":9,\"eventType\":\"order.status.changed\",\"payload\":{\"variables\":{\"status\":\"READY\"},\"recipientIds\":[11],\"recipientType\":\"MEMBERSHIP\",\"templateCode\":\"ORDER_STATUS_CHANGED\"}}");
String second = hashArgument();
assertThat(second).isEqualTo(first);
}
@Test void rejectsOversizedEventIdentityBeforeDatabaseRegistration() {
String message = "{\"sourceEventId\":\"" + "x".repeat(129)
+ "\",\"eventType\":\"order.status.changed\",\"tenantId\":9,\"payload\":{}}";
assertThatThrownBy(() -> consumer.onMessage(message))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("exceeds 128");
verifyNoInteractions(service);
}
private String hashArgument() {
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
verify(service).beginBusinessEvent(eq("source-1"), eq("order.status.changed"), hash.capture());
return hash.getValue();
}
private NotificationEventInbox event() {
NotificationEventInbox value = new NotificationEventInbox();
value.setId(7L); value.setPayloadHash("hash"); value.setStatus("PROCESSING");
return value;
}
private String message(String status) {
return "{\"sourceEventId\":\"source-1\",\"eventType\":\"order.status.changed\",\"tenantId\":9,\"payload\":{\"templateCode\":\"ORDER_STATUS_CHANGED\",\"recipientType\":\"MEMBERSHIP\",\"recipientIds\":[11],\"variables\":{\"status\":\"" + status + "\"}}}";
}
}
@@ -3,6 +3,7 @@ package com.baiyue.iam.notification;
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.NotificationTask;
import com.baiyue.iam.notification.entity.NotificationTemplate;
import com.baiyue.iam.notification.repository.NotificationRepository;
@@ -18,6 +19,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@@ -44,7 +46,8 @@ class NotificationServiceTest {
new NotificationRecipientResolver.ResolvedRecipient(9, 11, 12, "User", "MERCHANT")));
var request = new CreateNotificationRequest(9L, "ORDER_STATUS_CHANGED", RecipientType.MEMBERSHIP,
List.of(11L), null, Map.of("order_no", "A", "status", "READY"), null, "idem-key-1", null);
List.of(11L), null, Map.of("order_no", "A", "status", "READY"), null, "idem-key-1",
"source-1", "order.status.changed");
TaskListItem result = service.create(request, 99);
assertThat(result.status()).isEqualTo("SENT");
@@ -54,6 +57,8 @@ class NotificationServiceTest {
ArgumentCaptor<NotificationTask> task = ArgumentCaptor.forClass(NotificationTask.class);
verify(repository).saveTask(task.capture());
assertThat(task.getValue().getTitle()).isEqualTo("A");
assertThat(task.getValue().getSourceEventId()).isEqualTo("source-1");
assertThat(task.getValue().getSourceEventType()).isEqualTo("order.status.changed");
assertThat(stored).hasSize(1);
assertThat(stored.get(0).getStatus()).isEqualTo("DELIVERED");
}
@@ -63,7 +68,7 @@ class NotificationServiceTest {
existing.setCategory("ORDER"); existing.setTitle("A"); existing.setStatus("SENT"); existing.setRecipientCount(1);
when(repository.taskByIdempotency("idem-key-1")).thenReturn(existing);
var request = new CreateNotificationRequest(9L, "ORDER_STATUS_CHANGED", RecipientType.MEMBERSHIP,
List.of(11L), null, Map.of(), null, "idem-key-1", null);
List.of(11L), null, Map.of(), null, "idem-key-1", null, null);
assertThat(service.create(request, 99).duplicated()).isTrue();
verify(repository, never()).insertTask(any());
}
@@ -77,6 +82,81 @@ class NotificationServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_INVALID_STATE");
}
@Test void recordsAndCompletesBusinessEventClaim() {
when(repository.insertEventIfAbsent(any())).thenReturn(true);
var claim = service.beginBusinessEvent("event-1", "order.status.changed", "hash-1");
assertThat(claim.status()).isEqualTo(NotificationService.EventClaimStatus.ACQUIRED);
assertThat(claim.event().getStatus()).isEqualTo("PROCESSING");
when(repository.completeEvent(eq(claim.event().getId()), eq("hash-1"),
eq(claim.event().getUpdatedAt()), eq(31L), any())).thenReturn(true);
service.completeBusinessEvent(claim, 31L);
verify(repository).completeEvent(eq(claim.event().getId()), eq("hash-1"),
eq(claim.event().getUpdatedAt()), eq(31L), any());
}
@Test void reusesProcessedEventWithSamePayload() {
NotificationEventInbox existing = event("PROCESSED", "hash-1");
when(repository.event("event-1", "order.status.changed")).thenReturn(existing);
var claim = service.beginBusinessEvent("event-1", "order.status.changed", "hash-1");
assertThat(claim.status()).isEqualTo(NotificationService.EventClaimStatus.PROCESSED);
verify(repository, never()).claimEvent(any(), any(), any());
}
@Test void rejectsSameEventIdentityWithDifferentPayload() {
when(repository.event("event-1", "order.status.changed")).thenReturn(event("PROCESSED", "hash-1"));
assertThatThrownBy(() -> service.beginBusinessEvent("event-1", "order.status.changed", "hash-2"))
.isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_EVENT_PAYLOAD_CONFLICT");
}
@Test void freshConcurrentClaimRemainsProcessing() {
NotificationEventInbox existing = event("PROCESSING", "hash-1");
when(repository.event("event-1", "order.status.changed")).thenReturn(existing);
when(repository.claimEvent(eq(existing), any(), any())).thenReturn(false);
var claim = service.beginBusinessEvent("event-1", "order.status.changed", "hash-1");
assertThat(claim.status()).isEqualTo(NotificationService.EventClaimStatus.PROCESSING);
}
@Test void failedEventCanBeClaimedForRocketMqRetry() {
NotificationEventInbox existing = event("FAILED", "hash-1");
when(repository.event("event-1", "order.status.changed")).thenReturn(existing);
when(repository.claimEvent(eq(existing), any(), any())).thenAnswer(call -> {
existing.setUpdatedAt(call.getArgument(2));
return true;
});
var claim = service.beginBusinessEvent("event-1", "order.status.changed", "hash-1");
assertThat(claim.status()).isEqualTo(NotificationService.EventClaimStatus.ACQUIRED);
}
@Test void recordsTruncatedFailureForClaim() {
NotificationEventInbox existing = event("PROCESSING", "hash-1");
var claim = new NotificationService.EventClaim(existing, NotificationService.EventClaimStatus.ACQUIRED);
when(repository.failEvent(eq(existing.getId()), eq("hash-1"), eq(existing.getUpdatedAt()), any(), any()))
.thenReturn(true);
service.failBusinessEvent(claim, new IllegalStateException("x".repeat(1200)));
ArgumentCaptor<String> error = ArgumentCaptor.forClass(String.class);
verify(repository).failEvent(eq(existing.getId()), eq("hash-1"), eq(existing.getUpdatedAt()),
error.capture(), any());
assertThat(error.getValue()).hasSize(1000);
}
private NotificationEventInbox event(String status, String hash) {
NotificationEventInbox value = new NotificationEventInbox();
value.setId(7L); value.setSourceEventId("event-1"); value.setEventType("order.status.changed");
value.setPayloadHash(hash); value.setStatus(status); value.setUpdatedAt(LocalDateTime.now().minusMinutes(1));
return value;
}
private NotificationTemplate activeTemplate() {
NotificationTemplate value = new NotificationTemplate(); value.setId(1L);
value.setTemplateCode("ORDER_STATUS_CHANGED"); value.setCategory("ORDER");
@@ -3,5 +3,5 @@ import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class NotificationSqlContractTest {
@Test void definesTablesIsolationIdempotencyAndPermissions() throws Exception {String sql;try(var in=getClass().getResourceAsStream("/db/notification-center.sql")){assertThat(in).isNotNull();sql=new String(in.readAllBytes(),StandardCharsets.UTF_8);}assertThat(sql).contains("iam_notification_template","iam_notification_task","iam_notification_recipient","iam_notification_inbox","iam_notification_event_inbox","uk_notification_task_idempotency","uk_notification_source_event","uk_notification_recipient","idx_notification_inbox_list","platform:notification:template:status","platform:notification:task:retry","merchant:notification:view");}
@Test void definesTablesIsolationIdempotencyAndPermissions() throws Exception {String sql;try(var in=getClass().getResourceAsStream("/db/notification-center.sql")){assertThat(in).isNotNull();sql=new String(in.readAllBytes(),StandardCharsets.UTF_8);}assertThat(sql).contains("iam_notification_template","iam_notification_task","iam_notification_recipient","iam_notification_inbox","iam_notification_event_inbox","uk_notification_task_no","uk_notification_task_idempotency","uk_notification_source_event","uk_notification_recipient","idx_notification_inbox_list","platform:notification:template:status","platform:notification:task:retry","merchant:notification:view");}
}