diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java index c16bde8d9..6bbdfa29c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java @@ -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); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListener.java new file mode 100644 index 000000000..f74764c06 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListener.java @@ -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(); + 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()); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java index 0f4f9f410..ce3c4ee3a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java @@ -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( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java index ccf712c7f..06a8ea729 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java @@ -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()); + } + } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListenerTest.java new file mode 100644 index 000000000..6bf4d44bb --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListenerTest.java @@ -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 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 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"))); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewCompletedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewCompletedEvent.java new file mode 100644 index 000000000..0dbe49828 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewCompletedEvent.java @@ -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"); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java index 043298ef5..92d6db5d4 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java @@ -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()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java index d0945af29..26bdcade1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java @@ -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 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); diff --git a/web/src/features/notification/notification-content.test.ts b/web/src/features/notification/notification-content.test.ts index 3f44e1d3a..98357ffca 100644 --- a/web/src/features/notification/notification-content.test.ts +++ b/web/src/features/notification/notification-content.test.ts @@ -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', diff --git a/web/src/features/notification/notification-content.ts b/web/src/features/notification/notification-content.ts index 83f6ab20c..b0a477f93 100644 --- a/web/src/features/notification/notification-content.ts +++ b/web/src/features/notification/notification-content.ts @@ -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})`) : '' diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 96cf5f0be..4e6b2674e 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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." diff --git a/web/src/i18n/locales/ko.json b/web/src/i18n/locales/ko.json index 6ec9bb1c7..22440dad9 100644 --- a/web/src/i18n/locales/ko.json +++ b/web/src/i18n/locales/ko.json @@ -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}}의 검토를 요청했습니다." diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index c759b3f55..7f09dc1ea 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -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}} отправлен на проверку." diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 28655eb96..edc9edcf2 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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}} 已提交审核。"