merge: integrate reviewed Suite notification compatibility

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-11-06 13:57:04 +08:00
commit 0305eeb133
19 changed files with 1060 additions and 10 deletions

View file

@ -251,11 +251,14 @@ Web 端与 CLI 保持同一发布语义,只是在交互上可提供更明确
| `SkillDownloadedEvent` | 已发布版本下载结果构建成功并完成同步计数后 | 当前未发现生产消费者;下载计数由下载服务同步完成 |
| `SkillStarredEvent` | 收藏/取消 | 收藏计数 |
| `SkillRatedEvent` | 评分提交 | 评分重算 |
| `SkillSuiteReviewSubmittedEvent` | PUBLIC 或 NAMESPACE_ONLY 套件版本进入待审核 | 事务提交后异步创建 `REVIEW` 类站内通知;团队空间通知其 Owner/Admin,全局空间通知平台技能管理员,通知目标为对应审核任务 |
| `ReviewCompletedEvent` | 审核完成 | 预留给后续通知能力(当前可不消费) |
| `SkillPublishedEvent`(提升路径) | 首次提升或提升更新审核通过 | 重建目标全局 skill 的搜索文档;不是独立的 `SkillPromotedEvent` |
一期用 Spring ApplicationEvent + `@Async` 实现,后续可替换为消息队列。
套件提交审核通知沿用用户的“审核通知”偏好,关闭该类别后不创建站内通知。通知从本次提交的套件版本读取名称和版本号,点击后进入统一审核中心的对应任务;进入详情和批准权限仍由审核接口校验,收到通知不等于获得审核权限。通知为提交后的 best-effort 副作用,不保证进程终止后的补发,也不新增邮件投递。
### 审计日志写入策略
审计要求是同步落库,与业务操作在同一请求内记录,不以异步事件替代。审计是企业内部平台的刚性需求;普通 Web / CLI 下载链的已知实现缺口见第 3 节,不能将本要求视为所有入口均已实现的证明。

View file

@ -100,7 +100,11 @@ public class NotificationController extends BaseApiController {
String namespace = body.get("namespace") instanceof String value ? value : null;
String slug = body.get("slug") instanceof String value ? value : null;
if ("REVIEW_SUBMITTED".equals(eventType) && entityId != null) {
if ("SUITE_REVIEW_APPROVED".equals(eventType) || "SUITE_REVIEW_REJECTED".equals(eventType)) {
return new NotificationTarget("REVIEW", entityId, "/dashboard/review-progress");
}
if (("REVIEW_SUBMITTED".equals(eventType) || "SUITE_REVIEW_SUBMITTED".equals(eventType))
&& entityId != null) {
return new NotificationTarget("REVIEW", entityId, "/dashboard/reviews/" + entityId);
}
if ("PROFILE_REVIEW_SUBMITTED".equals(eventType) && entityId != null) {

View file

@ -0,0 +1,101 @@
package com.iflytek.skillhub.listener;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.suite.SkillSuiteRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.service.NotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/** Keeps Suite review notifications separate from Skill-specific event payloads. */
@Component
public class SkillSuiteReviewNotificationListener {
private static final Logger log = LoggerFactory.getLogger(SkillSuiteReviewNotificationListener.class);
private final SkillSuiteRepository suiteRepository;
private final SkillSuiteVersionRepository versionRepository;
private final NamespaceRepository namespaceRepository;
private final RecipientResolver recipientResolver;
private final NotificationService notificationService;
private final ObjectMapper objectMapper;
public SkillSuiteReviewNotificationListener(
SkillSuiteRepository suiteRepository,
SkillSuiteVersionRepository versionRepository,
NamespaceRepository namespaceRepository,
RecipientResolver recipientResolver,
NotificationService notificationService,
ObjectMapper objectMapper
) {
this.suiteRepository = suiteRepository;
this.versionRepository = versionRepository;
this.namespaceRepository = namespaceRepository;
this.recipientResolver = recipientResolver;
this.notificationService = notificationService;
this.objectMapper = objectMapper;
}
@Async("skillhubEventExecutor")
@TransactionalEventListener
public void onReviewSubmitted(SkillSuiteReviewSubmittedEvent event) {
var suite = suiteRepository.findById(event.suiteId()).orElse(null);
if (suite == null || !Objects.equals(suite.getNamespaceId(), event.namespaceId())) {
return;
}
var version = versionRepository.findById(event.versionId()).orElse(null);
if (version == null || !Objects.equals(version.getSuiteId(), suite.getId())) {
return;
}
var namespace = namespaceRepository.findById(event.namespaceId()).orElse(null);
if (namespace == null) {
return;
}
String displayName = version.getDisplayName();
String name = displayName != null && !displayName.isBlank() ? displayName : suite.getSlug();
Map<String, Object> body = new LinkedHashMap<>();
body.put("suiteId", suite.getId());
body.put("suiteName", name);
body.put("namespace", namespace.getSlug());
body.put("slug", suite.getSlug());
body.put("version", version.getVersion());
body.put("reviewId", event.reviewId());
body.put("submitterId", event.submitterId());
String json = toJson(body, event.reviewId());
var recipients = namespace.getType() == NamespaceType.GLOBAL
? recipientResolver.resolvePlatformSkillAdmins()
: recipientResolver.resolveNamespaceAdmins(event.namespaceId());
for (String recipient : recipients.stream().distinct().toList()) {
try {
notificationService.create(recipient, NotificationCategory.REVIEW,
"SUITE_REVIEW_SUBMITTED", "Suite review submitted",
json, "REVIEW", event.reviewId(), event.eventId(), event.occurredAt());
} catch (RuntimeException exception) {
log.warn("Failed to create Suite review notification [reviewId={}, recipientId={}]",
event.reviewId(), recipient);
}
}
}
private String toJson(Map<String, Object> body, Long reviewId) {
try {
return objectMapper.writeValueAsString(body);
} catch (JsonProcessingException exception) {
log.warn("Failed to serialize Suite review notification [reviewId={}]", reviewId);
return "{}";
}
}
}

View file

@ -0,0 +1,91 @@
package com.iflytek.skillhub.listener;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.review.ReviewSubjectType;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.suite.SkillSuiteRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.service.NotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
import java.util.LinkedHashMap;
import java.util.Objects;
/** Review outcomes belong to the submitter, independently of reviewer notifications. */
@Component
public class SkillSuiteReviewResultNotificationListener {
private static final Logger log = LoggerFactory.getLogger(SkillSuiteReviewResultNotificationListener.class);
private final ReviewTaskRepository reviews;
private final SkillSuiteRepository suites;
private final SkillSuiteVersionRepository versions;
private final NamespaceRepository namespaces;
private final NotificationService notifications;
private final ObjectMapper mapper;
public SkillSuiteReviewResultNotificationListener(ReviewTaskRepository reviews, SkillSuiteRepository suites,
SkillSuiteVersionRepository versions, NamespaceRepository namespaces,
NotificationService notifications, ObjectMapper mapper) {
this.reviews = reviews;
this.suites = suites;
this.versions = versions;
this.namespaces = namespaces;
this.notifications = notifications;
this.mapper = mapper;
}
@Async("skillhubEventExecutor")
@TransactionalEventListener
public void onReviewCompleted(SkillSuiteReviewCompletedEvent event) {
var task = reviews.findById(event.reviewId()).orElse(null);
if (task == null || task.getSubjectType() != ReviewSubjectType.SUITE_VERSION
|| task.getStatus() != event.status()
|| !Objects.equals(task.getSubjectId(), event.suiteId())
|| !Objects.equals(task.getSubjectVersionId(), event.versionId())
|| !Objects.equals(task.getNamespaceId(), event.namespaceId())
|| !Objects.equals(task.getSubmittedBy(), event.submitterId())) {
return;
}
var suite = suites.findById(event.suiteId()).orElse(null);
if (suite == null || !Objects.equals(suite.getNamespaceId(), event.namespaceId())) {
return;
}
var version = versions.findById(event.versionId()).orElse(null);
if (version == null || !Objects.equals(version.getSuiteId(), event.suiteId())) {
return;
}
var namespace = namespaces.findById(event.namespaceId()).orElse(null);
if (namespace == null) {
return;
}
var body = new LinkedHashMap<String, Object>();
body.put("suiteId", suite.getId());
body.put("suiteName", version.getDisplayName() == null || version.getDisplayName().isBlank()
? suite.getSlug() : version.getDisplayName());
body.put("namespace", namespace.getSlug());
body.put("slug", suite.getSlug());
body.put("version", version.getVersion());
body.put("reviewId", event.reviewId());
body.put("submitterId", event.submitterId());
body.put("eventId", event.eventId());
body.put("occurredAt", event.occurredAt().toString());
boolean approved = event.status() == ReviewTaskStatus.APPROVED;
try {
notifications.create(event.submitterId(), NotificationCategory.REVIEW,
approved ? "SUITE_REVIEW_APPROVED" : "SUITE_REVIEW_REJECTED",
approved ? "Suite review approved" : "Suite review rejected",
mapper.writeValueAsString(body), "REVIEW", event.reviewId(), event.eventId(), event.occurredAt());
} catch (JsonProcessingException | RuntimeException failure) {
log.warn("Failed to create Suite review result notification [reviewId={}, recipientId={}]",
event.reviewId(), event.submitterId());
}
}
}

View file

@ -68,6 +68,45 @@ class NotificationControllerTest {
verify(notificationService).list(org.mockito.ArgumentMatchers.eq("user-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class));
}
@Test
void list_shouldExposeReviewTargetRouteForSubmittedSuiteReviewNotifications() {
Notification notification = notification(
12L,
NotificationCategory.REVIEW,
"SUITE_REVIEW_SUBMITTED",
"{\"namespace\":\"demo\",\"slug\":\"writers\",\"suiteName\":\"Writing suite\"}",
"REVIEW",
101L
);
when(notificationService.list(org.mockito.ArgumentMatchers.eq("admin-1"),
org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW),
org.mockito.ArgumentMatchers.any(Pageable.class)))
.thenReturn(new PageImpl<>(java.util.List.of(notification)));
PageResponse<NotificationResponse> page = controller.list("admin-1", "REVIEW", 0, 20).data();
assertThat(page.items()).singleElement().satisfies(item -> {
assertThat(item.targetType()).isEqualTo("REVIEW");
assertThat(item.targetId()).isEqualTo(101L);
assertThat(item.targetRoute()).isEqualTo("/dashboard/reviews/101");
});
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"SUITE_REVIEW_APPROVED", "SUITE_REVIEW_REJECTED"})
void completedSuiteReviewLinksToTheSubmittersProgressInsteadOfReviewerOnlyDetails(String eventType) {
Notification item = notification(13L, NotificationCategory.REVIEW, eventType, "{}", "REVIEW", 101L);
when(notificationService.list(org.mockito.ArgumentMatchers.eq("user-1"),
org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW),
org.mockito.ArgumentMatchers.any(Pageable.class)))
.thenReturn(new PageImpl<>(java.util.List.of(item)));
var page = controller.list("user-1", "REVIEW", 0, 20).data();
assertThat(page.items()).singleElement().satisfies(result -> {
assertThat(result.targetId()).isEqualTo(101L);
assertThat(result.targetRoute()).isEqualTo("/dashboard/review-progress");
});
}
@Test
void list_shouldExposeProfileReviewTargetRouteForSubmittedProfileReviewNotifications() {
Notification notification = notification(

View file

@ -0,0 +1,218 @@
package com.iflytek.skillhub.listener;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository;
import com.iflytek.skillhub.notification.domain.Notification;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.domain.NotificationChannel;
import com.iflytek.skillhub.notification.domain.NotificationRepository;
import com.iflytek.skillhub.notification.service.NotificationPreferenceService;
import com.iflytek.skillhub.notification.service.NotificationService;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Clock;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SkillSuiteReviewNotificationListenerTest {
@Mock private SkillSuiteRepository suiteRepository;
@Mock private SkillSuiteVersionRepository versionRepository;
@Mock private NamespaceRepository namespaceRepository;
@Mock private RecipientResolver recipientResolver;
@Mock private NotificationService notificationService;
private final ObjectMapper mapper = new ObjectMapper();
private SkillSuiteReviewNotificationListener listener;
private SkillSuite suite;
private SkillSuiteVersion version;
private Namespace namespace;
private final SkillSuiteReviewSubmittedEvent event =
new SkillSuiteReviewSubmittedEvent(100L, 10L, 20L, "author", 5L);
@BeforeEach
void setUp() {
listener = listener(notificationService);
suite = new SkillSuite(5L, "writers", "Previously published name", "author");
ReflectionTestUtils.setField(suite, "id", 10L);
version = new SkillSuiteVersion(10L, "2.0.0", "Writing suite", "New summary",
SkillVisibility.PUBLIC, "author");
ReflectionTestUtils.setField(version, "id", 20L);
namespace = new Namespace("team", "Team", "owner");
}
@Test
void teamSubmissionNotifiesOnlyResolvedNamespaceAdministratorsOnceWithTheBoundVersion() throws Exception {
stubResources();
when(recipientResolver.resolveNamespaceAdmins(5L))
.thenReturn(List.of("owner", "admin", "owner"));
listener.onReviewSubmitted(event);
ArgumentCaptor<String> json = ArgumentCaptor.forClass(String.class);
verify(notificationService).create(eq("owner"), eq(NotificationCategory.REVIEW),
eq("SUITE_REVIEW_SUBMITTED"), eq("Suite review submitted"), json.capture(),
eq("REVIEW"), eq(100L), eq(event.eventId()), eq(event.occurredAt()));
verify(notificationService).create(eq("admin"), eq(NotificationCategory.REVIEW),
eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L), eq(event.eventId()), eq(event.occurredAt()));
var body = mapper.readTree(json.getValue());
assertThat(body.path("suiteId").asLong()).isEqualTo(10L);
assertThat(body.path("suiteName").asText()).isEqualTo("Writing suite");
assertThat(body.path("namespace").asText()).isEqualTo("team");
assertThat(body.path("slug").asText()).isEqualTo("writers");
assertThat(body.path("version").asText()).isEqualTo("2.0.0");
assertThat(body.path("reviewId").asLong()).isEqualTo(100L);
assertThat(body.path("submitterId").asText()).isEqualTo("author");
assertThat(body.has("skillId")).isFalse();
verify(recipientResolver, never()).resolvePlatformSkillAdmins();
verify(notificationService, never()).create(eq("unrelated-member"), any(), any(), any(), any(), any(), any(), any(), any());
}
@Test
void globalSubmissionNotifiesPlatformSkillAdministratorsInsteadOfNamespaceMembers() {
namespace.setType(NamespaceType.GLOBAL);
stubResources();
when(recipientResolver.resolvePlatformSkillAdmins()).thenReturn(List.of("skill-admin", "super-admin"));
listener.onReviewSubmitted(event);
verify(notificationService).create(eq("skill-admin"), eq(NotificationCategory.REVIEW),
eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L), eq(event.eventId()), eq(event.occurredAt()));
verify(notificationService).create(eq("super-admin"), eq(NotificationCategory.REVIEW),
eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L), eq(event.eventId()), eq(event.occurredAt()));
verify(recipientResolver, never()).resolveNamespaceAdmins(anyLong());
}
@Test
void missingSuiteDoesNotCreateANotification() {
when(suiteRepository.findById(10L)).thenReturn(Optional.empty());
listener.onReviewSubmitted(event);
verifyNoInteractions(notificationService, recipientResolver);
}
@Test
void mismatchedNamespaceDoesNotExposeSuiteMetadataToAnotherSpace() {
when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite));
listener.onReviewSubmitted(new SkillSuiteReviewSubmittedEvent(100L, 10L, 20L, "author", 6L));
verifyNoInteractions(notificationService, recipientResolver, namespaceRepository, versionRepository);
}
@Test
void mismatchedVersionDoesNotCreateANotificationForAnotherSuite() {
when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite));
SkillSuiteVersion otherVersion = new SkillSuiteVersion(11L, "1.0.0", SkillVisibility.PUBLIC, "author");
when(versionRepository.findById(20L)).thenReturn(Optional.of(otherVersion));
listener.onReviewSubmitted(event);
verifyNoInteractions(notificationService, recipientResolver, namespaceRepository);
}
@Test
void oneRecipientFailureDoesNotStopOtherAdministratorsFromReceivingTheRequest() {
stubResources();
when(recipientResolver.resolveNamespaceAdmins(5L)).thenReturn(List.of("unavailable-admin", "owner"));
doThrow(new IllegalStateException("Controlled notification storage failure"))
.when(notificationService).create(eq("unavailable-admin"), any(), any(), any(), any(), any(), any(), any(), any());
listener.onReviewSubmitted(event);
verify(notificationService).create(eq("owner"), eq(NotificationCategory.REVIEW),
eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L), eq(event.eventId()), eq(event.occurredAt()));
}
@Test
void suiteReviewUsesTheExistingReviewPreferenceRatherThanASeparateNotificationSetting() {
stubResources();
when(recipientResolver.resolveNamespaceAdmins(5L)).thenReturn(List.of("muted-owner", "admin"));
NotificationRepository notifications = mock(NotificationRepository.class);
NotificationPreferenceService preferences = mock(NotificationPreferenceService.class);
when(preferences.isEnabled("muted-owner", NotificationCategory.REVIEW, NotificationChannel.IN_APP))
.thenReturn(false);
when(preferences.isEnabled("admin", NotificationCategory.REVIEW, NotificationChannel.IN_APP))
.thenReturn(true);
NotificationService service = new NotificationService(notifications, preferences, Clock.systemUTC());
listener(service).onReviewSubmitted(event);
ArgumentCaptor<Notification> saved = ArgumentCaptor.forClass(Notification.class);
verify(notifications).save(saved.capture());
assertThat(saved.getValue().getRecipientId()).isEqualTo("admin");
assertThat(saved.getValue().getCategory()).isEqualTo(NotificationCategory.REVIEW);
}
@Test
void maximumLengthSuiteNameRemainsInTheBodyWithoutOverflowingTheNotificationTitle() throws Exception {
String longName = "套".repeat(256);
version.setDisplayName(longName);
stubResources();
when(recipientResolver.resolveNamespaceAdmins(5L)).thenReturn(List.of("owner"));
listener.onReviewSubmitted(event);
ArgumentCaptor<String> title = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
verify(notificationService).create(eq("owner"), any(), anyString(), title.capture(), body.capture(),
eq("REVIEW"), eq(100L), eq(event.eventId()), eq(event.occurredAt()));
assertThat(title.getValue()).hasSizeLessThanOrEqualTo(200);
assertThat(mapper.readTree(body.getValue()).path("suiteName").asText()).isEqualTo(longName);
}
@Test
void replayKeepsTheSameEmailIdentityForEveryRecipient() {
stubResources();
when(recipientResolver.resolveNamespaceAdmins(5L)).thenReturn(List.of("owner", "admin"));
listener.onReviewSubmitted(event);
listener.onReviewSubmitted(event);
for (String recipient : List.of("owner", "admin")) {
verify(notificationService, org.mockito.Mockito.times(2)).create(eq(recipient),
eq(NotificationCategory.REVIEW), eq("SUITE_REVIEW_SUBMITTED"),
anyString(), anyString(), eq("REVIEW"), eq(100L),
eq(event.eventId()), eq(event.occurredAt()));
}
}
private void stubResources() {
when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite));
when(versionRepository.findById(20L)).thenReturn(Optional.of(version));
when(namespaceRepository.findById(5L)).thenReturn(Optional.of(namespace));
}
private SkillSuiteReviewNotificationListener listener(NotificationService notifications) {
return new SkillSuiteReviewNotificationListener(suiteRepository, versionRepository, namespaceRepository,
recipientResolver, notifications, mapper);
}
}

View file

@ -0,0 +1,127 @@
package com.iflytek.skillhub.listener;
import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent;
import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.service.NotificationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.after;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** Exercises Spring's real transactional event delivery; database lifecycle coverage is separate. */
@SpringBootTest
@ActiveProfiles("test")
class SkillSuiteReviewNotificationTransactionTest {
@Autowired private ApplicationEventPublisher events;
@Autowired private PlatformTransactionManager transactions;
@MockBean private SkillSuiteRepository suites;
@MockBean private SkillSuiteVersionRepository versions;
@MockBean private NamespaceRepository namespaces;
@MockBean private RecipientResolver recipients;
@MockBean private NotificationService notifications;
@MockBean private ReviewTaskRepository reviewTasks;
private final SkillSuiteReviewSubmittedEvent event =
new SkillSuiteReviewSubmittedEvent(912350L, 912351L, 912352L, "author", 912353L);
@BeforeEach
void setUp() {
SkillSuite suite = new SkillSuite(912353L, "suite-event-test", "Suite", "author");
ReflectionTestUtils.setField(suite, "id", 912351L);
SkillSuiteVersion version = new SkillSuiteVersion(
912351L, "1.0.0", "Suite snapshot", "Summary", SkillVisibility.PUBLIC, "author");
Namespace namespace = new Namespace("suite-review-team", "Team", "owner");
when(suites.findById(912351L)).thenReturn(Optional.of(suite));
when(versions.findById(912352L)).thenReturn(Optional.of(version));
when(namespaces.findById(912353L)).thenReturn(Optional.of(namespace));
when(recipients.resolveNamespaceAdmins(912353L)).thenReturn(List.of("suite-review-admin"));
}
@Test
void committedSubmissionNotifiesOnlyAfterTheTransactionCompletes() {
new TransactionTemplate(transactions).executeWithoutResult(status -> {
events.publishEvent(event);
verify(notifications, never()).create(any(), any(), any(), any(), any(), any(), any(), any(), any());
});
verify(notifications, timeout(5000)).create(eq("suite-review-admin"), eq(NotificationCategory.REVIEW),
eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(912350L), eq(event.eventId()), eq(event.occurredAt()));
}
@Test
void rolledBackSubmissionDoesNotNotify() {
new TransactionTemplate(transactions).executeWithoutResult(status -> {
events.publishEvent(event);
status.setRollbackOnly();
});
verify(notifications, after(500).never()).create(any(), any(), any(), any(), any(), any(), any(), any(), any());
}
@Test
void eventOutsideATransactionDoesNotBypassTheCommitBoundary() {
events.publishEvent(event);
verify(notifications, after(500).never()).create(any(), any(), any(), any(), any(), any(), any(), any(), any());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(value = ReviewTaskStatus.class, names = {"APPROVED", "REJECTED"})
void committedResultNotifiesTheSubmitterOnlyAfterCommit(ReviewTaskStatus result) {
var completed = completed(result);
new TransactionTemplate(transactions).executeWithoutResult(status -> {
events.publishEvent(completed);
verify(notifications, never()).create(any(), any(), any(), any(), any(), any(), any(), any(), any());
});
verify(notifications, timeout(5000)).create(eq("author"), eq(NotificationCategory.REVIEW),
eq("SUITE_REVIEW_" + result), anyString(), anyString(), eq("REVIEW"), eq(912350L), eq(completed.eventId()), eq(completed.occurredAt()));
}
@Test
void rolledBackResultDoesNotNotify() {
var completed = completed(ReviewTaskStatus.APPROVED);
new TransactionTemplate(transactions).executeWithoutResult(status -> {
events.publishEvent(completed);
status.setRollbackOnly();
});
verify(notifications, after(500).never()).create(any(), any(), any(), any(), any(), any(), any(), any(), any());
}
private SkillSuiteReviewCompletedEvent completed(ReviewTaskStatus result) {
ReviewTask task = ReviewTask.forSuiteVersion(912352L, 912351L, 912353L, "1.0.0", "author");
task.setStatus(result);
when(reviewTasks.findById(912350L)).thenReturn(Optional.of(task));
return new SkillSuiteReviewCompletedEvent(912350L, 912351L, 912352L, "author", "suite-review-admin",
912353L, result, "00000000-0000-0000-0000-000000912350", java.time.Instant.now());
}
}

View file

@ -0,0 +1,163 @@
package com.iflytek.skillhub.listener;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository;
import com.iflytek.skillhub.notification.domain.Notification;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.domain.NotificationChannel;
import com.iflytek.skillhub.notification.domain.NotificationRepository;
import com.iflytek.skillhub.notification.service.NotificationPreferenceService;
import com.iflytek.skillhub.notification.service.NotificationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Clock;
import java.time.Instant;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SkillSuiteReviewResultNotificationListenerTest {
@Mock private ReviewTaskRepository reviews;
@Mock private SkillSuiteRepository suites;
@Mock private SkillSuiteVersionRepository versions;
@Mock private NamespaceRepository namespaces;
@Mock private NotificationService notifications;
private final ObjectMapper mapper = new ObjectMapper();
private SkillSuiteReviewResultNotificationListener listener;
@BeforeEach
void setUp() {
listener = new SkillSuiteReviewResultNotificationListener(reviews, suites, versions,
namespaces, notifications, mapper);
}
@ParameterizedTest
@EnumSource(value = ReviewTaskStatus.class, names = {"APPROVED", "REJECTED"})
void resultNotifiesTheVersionSubmitterWithoutCopyingReviewerComments(ReviewTaskStatus status) throws Exception {
stub(status, "submitter");
var event = event(status, "submitter");
listener.onReviewCompleted(event);
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
verify(notifications).create(eq("submitter"), eq(NotificationCategory.REVIEW),
eq("SUITE_REVIEW_" + status), anyString(), body.capture(), eq("REVIEW"), eq(30L), eq(event.eventId()), eq(event.occurredAt()));
var json = mapper.readTree(body.getValue());
assertThat(json.path("suiteName").asText()).isEqualTo("Current Suite");
assertThat(json.path("version").asText()).isEqualTo("2.0.0");
assertThat(json.path("eventId").asText()).isEqualTo(event.eventId());
assertThat(json.path("occurredAt").asText()).isEqualTo(event.occurredAt().toString());
assertThat(json.has("skillId")).isFalse();
assertThat(body.getValue()).doesNotContain("Private review comment");
verifyNoMoreInteractions(notifications);
}
@Test
void mismatchedSubmitterDoesNotReceiveAnotherAuthorsResult() {
ReviewTask task = ReviewTask.forSuiteVersion(20L, 10L, 5L, "2.0.0", "real-submitter");
task.setStatus(ReviewTaskStatus.APPROVED);
when(reviews.findById(30L)).thenReturn(Optional.of(task));
listener.onReviewCompleted(event(ReviewTaskStatus.APPROVED, "other-user"));
verifyNoInteractions(notifications, suites, versions, namespaces);
}
@Test
void staleTaskStatusDoesNotProduceAFalseOutcome() {
ReviewTask task = ReviewTask.forSuiteVersion(20L, 10L, 5L, "2.0.0", "submitter");
when(reviews.findById(30L)).thenReturn(Optional.of(task));
listener.onReviewCompleted(event(ReviewTaskStatus.APPROVED, "submitter"));
verifyNoInteractions(notifications, suites, versions, namespaces);
}
@Test
void notificationFailureIsIsolatedFromTheAlreadyCommittedReview() {
stub(ReviewTaskStatus.APPROVED, "submitter");
doThrow(new IllegalStateException("Controlled notification failure"))
.when(notifications).create(any(), any(), any(), any(), any(), any(), any(), any(), any());
assertThatCode(() -> listener.onReviewCompleted(event(ReviewTaskStatus.APPROVED, "submitter")))
.doesNotThrowAnyException();
}
@Test
void resultUsesTheExistingReviewPreferenceAndDoesNotRequireAnEmailAddress() {
stub(ReviewTaskStatus.REJECTED, "submitter");
NotificationRepository stored = mock(NotificationRepository.class);
NotificationPreferenceService preferences = mock(NotificationPreferenceService.class);
when(preferences.isEnabled("submitter", NotificationCategory.REVIEW, NotificationChannel.IN_APP))
.thenReturn(true);
var realService = new NotificationService(stored, preferences, Clock.systemUTC());
new SkillSuiteReviewResultNotificationListener(reviews, suites, versions, namespaces, realService, mapper)
.onReviewCompleted(event(ReviewTaskStatus.REJECTED, "submitter"));
ArgumentCaptor<Notification> saved = ArgumentCaptor.forClass(Notification.class);
verify(stored).save(saved.capture());
assertThat(saved.getValue().getRecipientId()).isEqualTo("submitter");
}
@Test
void replayedCompletionPreservesTheActualMailCommandIdentityAndTime() {
stub(ReviewTaskStatus.APPROVED, "submitter");
var stored = mock(NotificationRepository.class);
var preferences = mock(NotificationPreferenceService.class);
var enqueuer = mock(com.iflytek.skillhub.notification.email.delivery.NotificationEmailEnqueuer.class);
@SuppressWarnings("unchecked")
var provider = (org.springframework.beans.factory.ObjectProvider<com.iflytek.skillhub.notification.email.delivery.NotificationEmailEnqueuer>)
mock(org.springframework.beans.factory.ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(enqueuer);
var service = new NotificationService(stored, preferences, Clock.systemUTC());
ReflectionTestUtils.setField(service, "emailEnqueuers", provider);
var actual = new SkillSuiteReviewResultNotificationListener(reviews, suites, versions, namespaces, service, mapper);
var event = event(ReviewTaskStatus.APPROVED, "submitter");
actual.onReviewCompleted(event);
actual.onReviewCompleted(event);
var commands = ArgumentCaptor.forClass(com.iflytek.skillhub.notification.email.delivery.NotificationEmailCommand.class);
verify(enqueuer, times(2)).enqueue(commands.capture());
assertThat(commands.getAllValues()).allSatisfy(command -> {
assertThat(command.eventKey()).isEqualTo("SUITE_REVIEW_APPROVED:" + event.eventId());
assertThat(command.occurredAt()).isEqualTo(event.occurredAt());
assertThat(command.entityType()).isEqualTo("REVIEW");
assertThat(command.entityId()).isEqualTo(30L);
});
}
private SkillSuiteReviewCompletedEvent event(ReviewTaskStatus status, String submitter) {
return new SkillSuiteReviewCompletedEvent(30L, 10L, 20L, submitter, "reviewer", 5L,
status, "00000000-0000-0000-0000-000000000030", Instant.parse("2026-11-06T00:00:00Z"));
}
private void stub(ReviewTaskStatus status, String submitter) {
ReviewTask task = ReviewTask.forSuiteVersion(20L, 10L, 5L, "2.0.0", submitter);
task.setStatus(status);
task.setReviewComment("Private review comment");
when(reviews.findById(30L)).thenReturn(Optional.of(task));
SkillSuite suite = new SkillSuite(5L, "writers", "Old name", "original-creator");
ReflectionTestUtils.setField(suite, "id", 10L);
when(suites.findById(10L)).thenReturn(Optional.of(suite));
SkillSuiteVersion version = new SkillSuiteVersion(10L, "2.0.0", "Current Suite", "Summary",
SkillVisibility.PUBLIC, "submitter");
when(versions.findById(20L)).thenReturn(Optional.of(version));
when(namespaces.findById(5L)).thenReturn(Optional.of(new Namespace("team", "Team", "namespace-owner")));
}
}

View file

@ -0,0 +1,27 @@
package com.iflytek.skillhub.domain.event;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import java.time.Instant;
import java.util.Objects;
/** Immutable result of one Suite review; delivered only after the transaction commits. */
public record SkillSuiteReviewCompletedEvent(
Long reviewId,
Long suiteId,
Long versionId,
String submitterId,
String reviewerId,
Long namespaceId,
ReviewTaskStatus status,
String eventId,
Instant occurredAt
) {
public SkillSuiteReviewCompletedEvent {
if (status != ReviewTaskStatus.APPROVED && status != ReviewTaskStatus.REJECTED) {
throw new IllegalArgumentException("Suite review result must be approved or rejected");
}
Objects.requireNonNull(eventId, "eventId");
Objects.requireNonNull(occurredAt, "occurredAt");
}
}

View file

@ -0,0 +1,27 @@
package com.iflytek.skillhub.domain.event;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/** A Suite version has entered review; consumers run only after its transaction commits. */
public record SkillSuiteReviewSubmittedEvent(
Long reviewId,
Long suiteId,
Long versionId,
String submitterId,
Long namespaceId,
String eventId,
Instant occurredAt
) {
public SkillSuiteReviewSubmittedEvent(Long reviewId, Long suiteId, Long versionId,
String submitterId, Long namespaceId) {
this(reviewId, suiteId, versionId, submitterId, namespaceId,
UUID.randomUUID().toString(), Instant.now());
}
public SkillSuiteReviewSubmittedEvent {
Objects.requireNonNull(eventId);
Objects.requireNonNull(occurredAt);
}
}

View file

@ -2,6 +2,8 @@ package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent;
import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
@ -18,6 +20,7 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -27,6 +30,7 @@ import java.util.Comparator;
import java.util.ConcurrentModificationException;
import java.util.EnumSet;
import java.util.Set;
import java.util.UUID;
/** Coordinates Suite version review and publication without changing any member lifecycle. */
@Service
@ -41,6 +45,7 @@ public class SkillSuiteLifecycleService {
private final NamespaceRepository namespaceRepository;
private final SkillSuitePublicationValidator publicationValidator;
private final AuditLogService auditLogService;
private final ApplicationEventPublisher eventPublisher;
private final Clock clock;
private final boolean reviewWritesEnabled;
@ -52,6 +57,7 @@ public class SkillSuiteLifecycleService {
NamespaceRepository namespaceRepository,
SkillSuitePublicationValidator publicationValidator,
AuditLogService auditLogService,
ApplicationEventPublisher eventPublisher,
Clock clock,
@Value("${skillhub.suite.review-writes-enabled}") boolean reviewWritesEnabled
) {
@ -62,6 +68,7 @@ public class SkillSuiteLifecycleService {
this.namespaceRepository = namespaceRepository;
this.publicationValidator = publicationValidator;
this.auditLogService = auditLogService;
this.eventPublisher = eventPublisher;
this.clock = clock;
this.reviewWritesEnabled = reviewWritesEnabled;
if (!reviewWritesEnabled) {
@ -91,6 +98,8 @@ public class SkillSuiteLifecycleService {
versionId, suiteId, loaded.suite().getNamespaceId(),
loaded.version().getVersion(), context.actorUserId()));
audit(context, "SUBMIT_SKILL_SUITE_REVIEW", "SKILL_SUITE_VERSION", versionId, null);
eventPublisher.publishEvent(new SkillSuiteReviewSubmittedEvent(
task.getId(), suiteId, versionId, context.actorUserId(), loaded.suite().getNamespaceId()));
log.info("Suite review submitted [suiteId={}, versionId={}, actorId={}, requestId={}]",
suiteId, versionId, context.actorUserId(), context.requestId());
return task;
@ -133,6 +142,10 @@ public class SkillSuiteLifecycleService {
publish(loaded.suite(), loaded.version(), context.actorUserId());
audit(context, "APPROVE_SKILL_SUITE_REVIEW", "REVIEW_TASK", reviewTaskId,
AuditDetail.of("suiteVersionId", loaded.version().getId()));
eventPublisher.publishEvent(new SkillSuiteReviewCompletedEvent(
task.getId(), loaded.suite().getId(), loaded.version().getId(), task.getSubmittedBy(),
context.actorUserId(), loaded.suite().getNamespaceId(), ReviewTaskStatus.APPROVED,
UUID.randomUUID().toString(), Instant.now(clock)));
log.info("Suite review approved [reviewTaskId={}, suiteId={}, versionId={}, actorId={}, requestId={}]",
reviewTaskId, loaded.suite().getId(), loaded.version().getId(),
context.actorUserId(), context.requestId());
@ -153,6 +166,10 @@ public class SkillSuiteLifecycleService {
versionRepository.save(loaded.version());
audit(context, "REJECT_SKILL_SUITE_REVIEW", "REVIEW_TASK", reviewTaskId,
AuditDetail.of("suiteVersionId", loaded.version().getId()));
eventPublisher.publishEvent(new SkillSuiteReviewCompletedEvent(
task.getId(), loaded.suite().getId(), loaded.version().getId(), task.getSubmittedBy(),
context.actorUserId(), loaded.suite().getNamespaceId(), ReviewTaskStatus.REJECTED,
UUID.randomUUID().toString(), Instant.now(clock)));
log.info("Suite review rejected [reviewTaskId={}, suiteId={}, versionId={}, actorId={}, requestId={}]",
reviewTaskId, loaded.suite().getId(), loaded.version().getId(),
context.actorUserId(), context.requestId());

View file

@ -0,0 +1,40 @@
package com.iflytek.skillhub.domain.event;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class BusinessEventIdentityCompatibilityTest {
@Test
void snapshotConstructorsKeepProducerTimeAndGenerateOneStableIdentity() throws Exception {
var time = Instant.parse("2026-01-01T00:00:00Z");
var snapshot = new BusinessEventSnapshot(1L, SkillVisibility.PUBLIC, "actor", "user", "web", time);
List<SkillBusinessEvent> events = List.of(
new SkillPublishedEvent(1L, 2L, "actor", snapshot),
new SkillVersionYankedEvent(1L, 2L, "actor", true, snapshot),
new ReviewApprovedEvent(3L, 1L, 2L, "actor", "author", snapshot),
new ReviewRejectedEvent(3L, 1L, 2L, "actor", "author", "reason", snapshot),
new PromotionApprovedEvent(3L, 1L, "actor", "author", snapshot),
new PromotionRejectedEvent(3L, 1L, "actor", "author", "reason", snapshot));
for (var event : events) {
assertThat(event.snapshot()).isSameAs(snapshot);
assertThat(event.getClass().getMethod("occurredAt").invoke(event)).isEqualTo(time);
var accessor = event.getClass().getMethod("eventId");
assertThat((String) accessor.invoke(event)).isNotBlank().isEqualTo(accessor.invoke(event));
}
}
@Test
void emailIdentityConstructorDoesNotInventABusinessSnapshot() {
var time = Instant.parse("2026-01-01T00:00:00Z");
var event = new SkillPublishedEvent(1L, 2L, "actor", "replayed-event", time);
assertThat(event.snapshot()).isNull();
assertThat(event.eventId()).isEqualTo("replayed-event");
assertThat(event.occurredAt()).isEqualTo(time);
var next = new SkillPublishedEvent(1L, 2L, "actor");
assertThat(next.snapshot()).isNull();
assertThat(next.eventId()).isNotEqualTo(new SkillPublishedEvent(1L, 2L, "actor").eventId());
}
}

View file

@ -1,6 +1,8 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent;
import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
@ -17,8 +19,12 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import java.time.Clock;
import java.time.Instant;
@ -45,6 +51,7 @@ class SkillSuiteLifecycleServiceTest {
@Mock private NamespaceRepository namespaceRepository;
@Mock private SkillSuitePublicationValidator publicationValidator;
@Mock private AuditLogService auditLogService;
@Mock private ApplicationEventPublisher eventPublisher;
private SkillSuiteLifecycleService service;
private SkillSuite suite;
@ -55,7 +62,7 @@ class SkillSuiteLifecycleServiceTest {
Clock clock = Clock.fixed(Instant.parse("2026-09-07T08:00:00Z"), ZoneOffset.UTC);
service = new SkillSuiteLifecycleService(
suiteRepository, versionRepository, reviewTaskRepository, new ReviewPermissionChecker(), namespaceRepository,
publicationValidator, auditLogService, clock, true);
publicationValidator, auditLogService, eventPublisher, clock, true);
suite = new SkillSuite(1L, "writers", "Writers", "author");
version = new SkillSuiteVersion(10L, "1.0.0", SkillVisibility.PRIVATE, "author");
setId(suite, 10L);
@ -75,6 +82,7 @@ class SkillSuiteLifecycleServiceTest {
verify(auditLogService).record(
"author", "PUBLISH_SKILL_SUITE_VERSION", "SKILL_SUITE_VERSION", 20L,
"request-1", "127.0.0.1", "test", null);
verify(eventPublisher, never()).publishEvent(any(Object.class));
}
@Test
@ -93,12 +101,17 @@ class SkillSuiteLifecycleServiceTest {
verify(suiteRepository, never()).save(any());
}
@Test
void publicDraftCreatesTypedReviewAndBecomesPending() {
version.setVisibility(SkillVisibility.PUBLIC);
@ParameterizedTest
@EnumSource(value = SkillVisibility.class, names = {"PUBLIC", "NAMESPACE_ONLY"})
void shareableDraftCreatesTypedReviewAndBecomesPending(SkillVisibility visibility) throws Exception {
version.setVisibility(visibility);
stubLoaded();
when(versionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(reviewTaskRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(reviewTaskRepository.save(any())).thenAnswer(invocation -> {
ReviewTask task = invocation.getArgument(0);
setId(task, 30L);
return task;
});
ReviewTask task = service.submitForReview(10L, 20L, context());
@ -107,6 +120,46 @@ class SkillSuiteLifecycleServiceTest {
assertThat(task.getSubjectId()).isEqualTo(10L);
assertThat(task.getSubjectVersionId()).isEqualTo(20L);
verify(publicationValidator).validateForPublication(suite, version);
var submitted = org.mockito.ArgumentCaptor.forClass(SkillSuiteReviewSubmittedEvent.class);
verify(eventPublisher).publishEvent(submitted.capture());
assertThat(submitted.getValue().reviewId()).isEqualTo(30L);
assertThat(submitted.getValue().suiteId()).isEqualTo(10L);
assertThat(submitted.getValue().versionId()).isEqualTo(20L);
assertThat(submitted.getValue().submitterId()).isEqualTo("author");
assertThat(submitted.getValue().namespaceId()).isEqualTo(1L);
assertThat(submitted.getValue().eventId()).isNotBlank();
assertThat(submitted.getValue().occurredAt()).isNotNull();
}
@Test
void repeatedSubmissionDoesNotCreateAnotherReviewOrEmitAnotherNotificationEvent() {
version.setVisibility(SkillVisibility.NAMESPACE_ONLY);
version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW);
stubLoaded();
assertThatThrownBy(() -> service.submitForReview(10L, 20L, context()))
.isInstanceOf(DomainBadRequestException.class)
.hasMessage("error.suite.review.notDraft");
verify(reviewTaskRepository, never()).save(any());
verify(versionRepository, never()).save(any());
verify(eventPublisher, never()).publishEvent(any(Object.class));
}
@Test
void failedSubmissionAuditDoesNotEmitANotificationEvent() {
version.setVisibility(SkillVisibility.PUBLIC);
stubLoaded();
when(versionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(reviewTaskRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
doThrow(new IllegalStateException("Controlled audit failure")).when(auditLogService).record(
"author", "SUBMIT_SKILL_SUITE_REVIEW", "SKILL_SUITE_VERSION", 20L,
"request-1", "127.0.0.1", "test", null);
assertThatThrownBy(() -> service.submitForReview(10L, 20L, context()))
.isInstanceOf(IllegalStateException.class);
verify(eventPublisher, never()).publishEvent(any(Object.class));
}
@Test
@ -123,6 +176,7 @@ class SkillSuiteLifecycleServiceTest {
assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.DRAFT);
verify(reviewTaskRepository, never()).save(any());
verify(versionRepository, never()).save(any());
verify(eventPublisher, never()).publishEvent(any(Object.class));
}
@Test
@ -143,6 +197,54 @@ class SkillSuiteLifecycleServiceTest {
assertThat(task.getStatus()).isEqualTo(ReviewTaskStatus.APPROVED);
}
@ParameterizedTest
@EnumSource(value = ReviewTaskStatus.class, names = {"APPROVED", "REJECTED"})
void completedReviewEmitsOnlyOneBoundResultForTheActualSubmitter(ReviewTaskStatus result) throws Exception {
version.setVisibility(SkillVisibility.PUBLIC);
version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW);
ReviewTask task = pendingReview("version-submitter");
stubLoaded();
when(reviewTaskRepository.findById(30L)).thenReturn(Optional.of(task));
when(reviewTaskRepository.updateStatusWithVersion(30L, result, "owner", "Decision", 1)).thenReturn(1);
when(versionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
if (result == ReviewTaskStatus.APPROVED) {
when(suiteRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
service.approveReview(30L, "Decision", adminContext());
} else {
service.rejectReview(30L, "Decision", adminContext());
}
ArgumentCaptor<SkillSuiteReviewCompletedEvent> event =
ArgumentCaptor.forClass(SkillSuiteReviewCompletedEvent.class);
verify(eventPublisher).publishEvent(event.capture());
assertThat(event.getValue().reviewId()).isEqualTo(30L);
assertThat(event.getValue().suiteId()).isEqualTo(10L);
assertThat(event.getValue().versionId()).isEqualTo(20L);
assertThat(event.getValue().namespaceId()).isEqualTo(1L);
assertThat(event.getValue().submitterId()).isEqualTo("version-submitter");
assertThat(event.getValue().reviewerId()).isEqualTo("owner");
assertThat(event.getValue().status()).isEqualTo(result);
assertThat(event.getValue().occurredAt()).isEqualTo(Instant.parse("2026-09-07T08:00:00Z"));
assertThat(java.util.UUID.fromString(event.getValue().eventId())).isNotNull();
assertThatThrownBy(() -> service.approveReview(30L, "Again", adminContext()))
.isInstanceOf(DomainBadRequestException.class);
verify(eventPublisher).publishEvent(any(SkillSuiteReviewCompletedEvent.class));
}
@ParameterizedTest
@EnumSource(value = ReviewTaskStatus.class, names = {"APPROVED", "REJECTED"})
void failedReviewCompareAndSetDoesNotEmitAResult(ReviewTaskStatus result) throws Exception {
version.setVisibility(SkillVisibility.PUBLIC);
version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW);
stubLoaded();
when(reviewTaskRepository.findById(30L)).thenReturn(Optional.of(pendingReview("author")));
assertThatThrownBy(() -> {
if (result == ReviewTaskStatus.APPROVED) service.approveReview(30L, "Decision", adminContext());
else service.rejectReview(30L, "Decision", adminContext());
}).isInstanceOf(java.util.ConcurrentModificationException.class);
verify(eventPublisher, never()).publishEvent(any(Object.class));
}
@Test
void approvalFailureKeepsTheReviewAndSuiteVersionPending() throws Exception {
version.setVisibility(SkillVisibility.PUBLIC);
@ -210,7 +312,7 @@ class SkillSuiteLifecycleServiceTest {
void rolloutGateRejectsTypedReviewWritesBeforeLoadingTheSuite() {
SkillSuiteLifecycleService disabled = new SkillSuiteLifecycleService(
suiteRepository, versionRepository, reviewTaskRepository, new ReviewPermissionChecker(),
namespaceRepository, publicationValidator, auditLogService,
namespaceRepository, publicationValidator, auditLogService, eventPublisher,
Clock.systemUTC(), false);
assertThatThrownBy(() -> disabled.submitForReview(10L, 20L, context()))
@ -224,7 +326,7 @@ class SkillSuiteLifecycleServiceTest {
version.setVisibility(SkillVisibility.PUBLIC);
SkillSuiteLifecycleService disabled = new SkillSuiteLifecycleService(
suiteRepository, versionRepository, reviewTaskRepository, new ReviewPermissionChecker(),
namespaceRepository, publicationValidator, auditLogService,
namespaceRepository, publicationValidator, auditLogService, eventPublisher,
Clock.systemUTC(), false);
Set<SkillSuiteAllowedAction> actions = disabled.allowedActions(

View file

@ -2,6 +2,45 @@ import { describe, expect, it } from 'vitest'
import { resolveNotificationDisplay } from './notification-content'
describe('resolveNotificationDisplay', () => {
it.each([
['zh-CN', '套件审核提交'],
['en', 'Suite review submitted'],
['ko-KR', '스킬 모음 검토 제출'],
['ru', 'Набор отправлен на проверку'],
])('renders a Suite submission with its own title and bound version in %s', (language, title) => {
const display = resolveNotificationDisplay({
id: 1,
category: 'REVIEW',
eventType: 'SUITE_REVIEW_SUBMITTED',
title: 'Suite review submitted',
bodyJson: JSON.stringify({ suiteName: 'Writing suite', version: '2.0.0' }),
status: 'UNREAD',
createdAt: '2026-11-05T00:00:00Z',
}, language)
expect(display.title).toBe(title)
expect(display.description).toContain('Writing suite')
expect(display.description).toContain('2.0.0')
expect(display.description).not.toContain('{{')
})
it.each([
['zh', 'SUITE_REVIEW_APPROVED', '套件审核通过'],
['zh', 'SUITE_REVIEW_REJECTED', '套件审核未通过'],
['en', 'SUITE_REVIEW_APPROVED', 'Suite review approved'],
['ko', 'SUITE_REVIEW_REJECTED', '스킬 모음 검토 반려'],
['ru', 'SUITE_REVIEW_APPROVED', 'Проверка набора одобрена'],
])('renders a Suite result with its submitted version in %s', (language, eventType, title) => {
const display = resolveNotificationDisplay({
id: 2, category: 'REVIEW', eventType, title: 'Server title',
bodyJson: JSON.stringify({ suiteName: 'Writing suite', version: '2.0.0' }),
status: 'UNREAD', createdAt: '2026-11-06T00:00:00Z',
}, language)
expect(display.title).toBe(title)
expect(display.description).toContain('Writing suite')
expect(display.description).toContain('2.0.0')
})
it('renders review submitted content in Chinese', () => {
const display = resolveNotificationDisplay({
category: 'REVIEW',

View file

@ -8,6 +8,7 @@ export type NotificationDisplay = {
type NotificationBody = {
skillName?: string
suiteName?: string
version?: string
}
@ -24,6 +25,7 @@ function parseBody(bodyJson?: string): NotificationBody {
}
const localizedEvents = new Set([
'SUITE_REVIEW_SUBMITTED', 'SUITE_REVIEW_APPROVED', 'SUITE_REVIEW_REJECTED',
'REVIEW_SUBMITTED', 'REVIEW_APPROVED', 'REVIEW_REJECTED',
'PROMOTION_SUBMITTED', 'PROMOTION_APPROVED', 'PROMOTION_REJECTED',
'REPORT_SUBMITTED', 'REPORT_RESOLVED', 'SKILL_PUBLISHED',
@ -35,6 +37,8 @@ export function resolveNotificationDisplay(item: NotificationItem, language: str
const t = i18n.getFixedT(language)
const body = parseBody(item.bodyJson)
const skillName = body.skillName ?? ''
const suiteName = body.suiteName ?? ''
const resourceName = item.eventType.startsWith('SUITE_REVIEW_') ? suiteName : skillName
const version = body.version ?? ''
const versionSuffix = version ? (zh ? `(${version})` : ` (${version})`) : ''
@ -44,8 +48,8 @@ export function resolveNotificationDisplay(item: NotificationItem, language: str
return {
title: t(`notificationEvents.${item.eventType}.title`),
description: skillName
? t(`notificationEvents.${item.eventType}.description`, { skillName, versionSuffix })
description: resourceName
? t(`notificationEvents.${item.eventType}.description`, { skillName, suiteName, versionSuffix })
: '',
}
}

View file

@ -3725,6 +3725,18 @@
"days": "{{count}}d"
},
"notificationEvents": {
"SUITE_REVIEW_APPROVED": {
"title": "Suite review approved",
"description": "The review of {{suiteName}}{{versionSuffix}} was approved."
},
"SUITE_REVIEW_REJECTED": {
"title": "Suite review rejected",
"description": "The review of {{suiteName}}{{versionSuffix}} was rejected. Check review progress for details."
},
"SUITE_REVIEW_SUBMITTED": {
"title": "Suite review submitted",
"description": "{{suiteName}}{{versionSuffix}} was submitted for review."
},
"REVIEW_SUBMITTED": {
"title": "Review submitted",
"description": "{{skillName}}{{versionSuffix}} was submitted for review."

View file

@ -3723,6 +3723,18 @@
"days": "{{count}}일 전"
},
"notificationEvents": {
"SUITE_REVIEW_APPROVED": {
"title": "스킬 모음 검토 승인",
"description": "{{suiteName}}{{versionSuffix}} 검토가 승인되었습니다."
},
"SUITE_REVIEW_REJECTED": {
"title": "스킬 모음 검토 반려",
"description": "{{suiteName}}{{versionSuffix}} 검토가 반려되었습니다. 검토 진행 상황을 확인하세요."
},
"SUITE_REVIEW_SUBMITTED": {
"title": "스킬 모음 검토 제출",
"description": "{{suiteName}}{{versionSuffix}}의 검토를 요청했습니다."
},
"REVIEW_SUBMITTED": {
"title": "스킬 검토 제출",
"description": "{{skillName}}{{versionSuffix}}의 검토를 요청했습니다."

View file

@ -3729,6 +3729,18 @@
"days": "{{count}} д"
},
"notificationEvents": {
"SUITE_REVIEW_APPROVED": {
"title": "Проверка набора одобрена",
"description": "Проверка {{suiteName}}{{versionSuffix}} одобрена."
},
"SUITE_REVIEW_REJECTED": {
"title": "Проверка набора отклонена",
"description": "Проверка {{suiteName}}{{versionSuffix}} отклонена. Подробности доступны в ходе проверки."
},
"SUITE_REVIEW_SUBMITTED": {
"title": "Набор отправлен на проверку",
"description": "{{suiteName}}{{versionSuffix}} отправлен на проверку."
},
"REVIEW_SUBMITTED": {
"title": "Проверка отправлена",
"description": "{{skillName}}{{versionSuffix}} отправлен на проверку."

View file

@ -3723,6 +3723,18 @@
"days": "{{count}}天"
},
"notificationEvents": {
"SUITE_REVIEW_APPROVED": {
"title": "套件审核通过",
"description": "{{suiteName}}{{versionSuffix}} 的审核已通过。"
},
"SUITE_REVIEW_REJECTED": {
"title": "套件审核未通过",
"description": "{{suiteName}}{{versionSuffix}} 的审核未通过,请查看审核进度。"
},
"SUITE_REVIEW_SUBMITTED": {
"title": "套件审核提交",
"description": "{{suiteName}}{{versionSuffix}} 已提交审核。"
},
"REVIEW_SUBMITTED": {
"title": "技能审核提交",
"description": "{{skillName}}{{versionSuffix}} 已提交审核。"