mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
merge: integrate reviewed Suite notification compatibility
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
commit
0305eeb133
19 changed files with 1060 additions and 10 deletions
|
|
@ -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 节,不能将本要求视为所有入口均已实现的证明。
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
: '',
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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}}의 검토를 요청했습니다."
|
||||
|
|
|
|||
|
|
@ -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}} отправлен на проверку."
|
||||
|
|
|
|||
|
|
@ -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}} 已提交审核。"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue