mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
fix(notification): notify Suite submitters after review outcomes
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
58ae93873d
commit
d26154b228
14 changed files with 419 additions and 2 deletions
|
|
@ -100,6 +100,9 @@ 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 ("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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
} catch (JsonProcessingException | RuntimeException failure) {
|
||||
log.warn("Failed to create Suite review result notification [reviewId={}, recipientId={}]",
|
||||
event.reviewId(), event.submitterId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +92,21 @@ class NotificationControllerTest {
|
|||
});
|
||||
}
|
||||
|
||||
@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(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
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;
|
||||
|
|
@ -45,6 +49,7 @@ class SkillSuiteReviewNotificationTransactionTest {
|
|||
@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);
|
||||
|
|
@ -89,4 +94,34 @@ class SkillSuiteReviewNotificationTransactionTest {
|
|||
|
||||
verify(notifications, after(500).never()).create(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());
|
||||
});
|
||||
verify(notifications, timeout(5000)).create(eq("author"), eq(NotificationCategory.REVIEW),
|
||||
eq("SUITE_REVIEW_" + result), anyString(), anyString(), eq("REVIEW"), eq(912350L));
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
|
||||
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,137 @@
|
|||
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));
|
||||
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());
|
||||
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");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ 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;
|
||||
|
|
@ -29,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
|
||||
|
|
@ -140,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());
|
||||
|
|
@ -160,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());
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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;
|
||||
|
|
@ -21,6 +22,7 @@ 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;
|
||||
|
||||
|
|
@ -188,6 +190,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);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,23 @@ describe('resolveNotificationDisplay', () => {
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ function parseBody(bodyJson?: string): NotificationBody {
|
|||
}
|
||||
|
||||
const localizedEvents = new Set([
|
||||
'SUITE_REVIEW_SUBMITTED',
|
||||
'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',
|
||||
|
|
@ -38,7 +38,7 @@ export function resolveNotificationDisplay(item: NotificationItem, language: str
|
|||
const body = parseBody(item.bodyJson)
|
||||
const skillName = body.skillName ?? ''
|
||||
const suiteName = body.suiteName ?? ''
|
||||
const resourceName = item.eventType === 'SUITE_REVIEW_SUBMITTED' ? suiteName : skillName
|
||||
const resourceName = item.eventType.startsWith('SUITE_REVIEW_') ? suiteName : skillName
|
||||
const version = body.version ?? ''
|
||||
const versionSuffix = version ? (zh ? `(${version})` : ` (${version})`) : ''
|
||||
|
||||
|
|
|
|||
|
|
@ -3443,6 +3443,14 @@
|
|||
"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."
|
||||
|
|
|
|||
|
|
@ -3723,6 +3723,14 @@
|
|||
"days": "{{count}}일 전"
|
||||
},
|
||||
"notificationEvents": {
|
||||
"SUITE_REVIEW_APPROVED": {
|
||||
"title": "스킬 모음 검토 승인",
|
||||
"description": "{{suiteName}}{{versionSuffix}} 검토가 승인되었습니다."
|
||||
},
|
||||
"SUITE_REVIEW_REJECTED": {
|
||||
"title": "스킬 모음 검토 반려",
|
||||
"description": "{{suiteName}}{{versionSuffix}} 검토가 반려되었습니다. 검토 진행 상황을 확인하세요."
|
||||
},
|
||||
"SUITE_REVIEW_SUBMITTED": {
|
||||
"title": "스킬 모음 검토 제출",
|
||||
"description": "{{suiteName}}{{versionSuffix}}의 검토를 요청했습니다."
|
||||
|
|
|
|||
|
|
@ -3447,6 +3447,14 @@
|
|||
"days": "{{count}} д"
|
||||
},
|
||||
"notificationEvents": {
|
||||
"SUITE_REVIEW_APPROVED": {
|
||||
"title": "Проверка набора одобрена",
|
||||
"description": "Проверка {{suiteName}}{{versionSuffix}} одобрена."
|
||||
},
|
||||
"SUITE_REVIEW_REJECTED": {
|
||||
"title": "Проверка набора отклонена",
|
||||
"description": "Проверка {{suiteName}}{{versionSuffix}} отклонена. Подробности доступны в ходе проверки."
|
||||
},
|
||||
"SUITE_REVIEW_SUBMITTED": {
|
||||
"title": "Набор отправлен на проверку",
|
||||
"description": "{{suiteName}}{{versionSuffix}} отправлен на проверку."
|
||||
|
|
|
|||
|
|
@ -3441,6 +3441,14 @@
|
|||
"days": "{{count}}天"
|
||||
},
|
||||
"notificationEvents": {
|
||||
"SUITE_REVIEW_APPROVED": {
|
||||
"title": "套件审核通过",
|
||||
"description": "{{suiteName}}{{versionSuffix}} 的审核已通过。"
|
||||
},
|
||||
"SUITE_REVIEW_REJECTED": {
|
||||
"title": "套件审核未通过",
|
||||
"description": "{{suiteName}}{{versionSuffix}} 的审核未通过,请查看审核进度。"
|
||||
},
|
||||
"SUITE_REVIEW_SUBMITTED": {
|
||||
"title": "套件审核提交",
|
||||
"description": "{{suiteName}}{{versionSuffix}} 已提交审核。"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue