This commit is contained in:
2026-08-12 18:49:46 +08:00
parent 025467f9ed
commit f2f959e8a5
21 changed files with 1027 additions and 57 deletions
@@ -0,0 +1,72 @@
package com.baiyue.iam.notification;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class NotificationOpenApiContractTest {
@Test
void parsesAndExposesFrozenManagementAndInboxContracts() throws IOException {
String document;
try (var stream = getClass().getResourceAsStream("/openapi/notification-v1.yaml")) {
assertThat(stream).isNotNull();
document = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
Map<String, Object> api = new Yaml().load(document);
assertThat(api).containsEntry("openapi", "3.0.3");
Map<String, Object> paths = map(api.get("paths"));
assertThat(paths).containsKeys(
"/{app}/v1/notifications", "/{app}/v1/notifications/unread-count",
"/{app}/v1/notifications/{id}", "/{app}/v1/notifications/{id}/read",
"/{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}/cancel", "/platform/v1/notification-tasks/{id}/retry");
assertThat(paths).doesNotContainKey("/merchant/v1/notifications:create");
assertRequestBody(paths, "/platform/v1/notifications", "post");
assertRequestBody(paths, "/platform/v1/notification-templates", "post");
assertRequestBody(paths, "/platform/v1/notification-templates/{id}", "put");
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}/cancel",
"/platform/v1/notification-tasks/{id}/retry")) {
Map<String, Object> operation = map(map(paths.get(path)).values().iterator().next());
assertThat(list(operation.get("parameters"))).anySatisfy(value ->
assertThat(map(value)).containsEntry("$ref", "#/components/parameters/IdPath"));
}
Map<String, Object> components = map(api.get("components"));
Map<String, Object> schemas = map(components.get("schemas"));
assertThat(schemas).containsKeys("CreateNotificationRequest", "CreateTemplateRequest",
"UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxPageResponse",
"InboxDetailResponse", "UnreadCountResponse", "ReadAllResponse",
"TemplateItem", "TemplateDetailResponse", "TaskItem", "TaskDetailResponse");
for (String schema : List.of("CreateNotificationRequest", "CreateTemplateRequest",
"UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxItem", "TemplateItem",
"TaskItem", "InboxPageResponse", "TemplateDetailResponse", "TaskDetailResponse")) {
assertThat(list(map(schemas.get(schema)).get("required"))).isNotEmpty();
}
assertThat(document).contains("tenantId is a backend compatibility field")
.contains("DISABLED cannot return to ACTIVE")
.contains("does not support deleting or clearing read messages")
.contains("name: taskNo").contains("name: templateCode").contains("name: title");
}
private void assertRequestBody(Map<String, Object> paths, String path, String method) {
assertThat(map(map(paths.get(path)).get(method))).containsKey("requestBody");
}
@SuppressWarnings("unchecked")
private Map<String, Object> map(Object value) { return (Map<String, Object>) value; }
@SuppressWarnings("unchecked")
private List<Object> list(Object value) { return (List<Object>) value; }
}
@@ -0,0 +1,87 @@
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.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.NotificationService;
import com.baiyue.iam.notification.service.NotificationTemplateRenderer;
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.ArrayList;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class NotificationServiceTest {
private final NotificationRepository repository = mock(NotificationRepository.class);
private final NotificationRecipientResolver resolver = mock(NotificationRecipientResolver.class);
private final NotificationService service = new NotificationService();
@BeforeEach void setup() {
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "renderer", new NotificationTemplateRenderer());
ReflectionTestUtils.setField(service, "resolver", resolver);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
}
@Test void createsTaskAndImmutableRecipientSnapshot() {
List<NotificationRecipient> stored = new ArrayList<>();
doAnswer(call -> { stored.add(call.getArgument(0)); return null; }).when(repository).insertRecipient(any());
when(repository.recipients(anyLong())).thenAnswer(call -> List.copyOf(stored));
NotificationTemplate template = activeTemplate();
when(repository.templateByCode(0, "ORDER_STATUS_CHANGED")).thenReturn(template);
when(resolver.resolve(any())).thenReturn(List.of(
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);
TaskListItem result = service.create(request, 99);
assertThat(result.status()).isEqualTo("SENT");
verify(repository).insertTask(any());
verify(repository).insertRecipient(any());
verify(repository).insertInbox(any());
ArgumentCaptor<NotificationTask> task = ArgumentCaptor.forClass(NotificationTask.class);
verify(repository).saveTask(task.capture());
assertThat(task.getValue().getTitle()).isEqualTo("A");
assertThat(stored).hasSize(1);
assertThat(stored.get(0).getStatus()).isEqualTo("DELIVERED");
}
@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);
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);
assertThat(service.create(request, 99).duplicated()).isTrue();
verify(repository, never()).insertTask(any());
}
@Test void disabledTemplateCannotReturnToActive() {
NotificationTemplate template = new NotificationTemplate(); template.setId(1L);
template.setStatus("DISABLED"); template.setVersion(3);
when(repository.template(1L)).thenReturn(template);
assertThatThrownBy(() -> service.changeTemplateStatus(1L,
new ChangeTemplateStatusRequest("ACTIVE", 3), 9))
.isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_INVALID_STATE");
}
private NotificationTemplate activeTemplate() {
NotificationTemplate value = new NotificationTemplate(); value.setId(1L);
value.setTemplateCode("ORDER_STATUS_CHANGED"); value.setCategory("ORDER");
value.setTitleTemplate("{{order_no}}"); value.setBodyTemplate("{{status}}");
value.setVariableWhitelistJson("[\"order_no\",\"status\"]"); value.setStatus("ACTIVE");
return value;
}
}
@@ -0,0 +1,7 @@
package com.baiyue.iam.notification;
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");}
}
@@ -0,0 +1,12 @@
package com.baiyue.iam.notification;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.notification.service.NotificationTemplateRenderer;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.*;
class NotificationTemplateRendererTest {
private final NotificationTemplateRenderer renderer=new NotificationTemplateRenderer();
@Test void rendersOnlyWhitelistedVariables(){assertThat(renderer.render("Order {{order_no}} is {{status}}",Set.of("order_no","status"),Map.of("order_no","A1","status","ready"))).isEqualTo("Order A1 is ready");}
@Test void rejectsMissingAndUnknownVariables(){assertThatThrownBy(()->renderer.render("{{order_no}}",Set.of("order_no"),Map.of())).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_MISSING");assertThatThrownBy(()->renderer.render("ok",Set.of(),Map.of("secret","x"))).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_NOT_ALLOWED");}
}