fix: emit confirm-publish events with permission-aware subscriber fanout

Signed-off-by: 千乘妍 (Xiaoyaner) <258399167+xiaoyaner0201@users.noreply.github.com>
This commit is contained in:
千乘妍 (Xiaoyaner) 2026-08-20 02:46:12 +00:00
parent d2403bb591
commit e071afb4f0
16 changed files with 943 additions and 8 deletions

View file

@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.social.SkillSubscriptionService;
import com.iflytek.skillhub.domain.social.SubscriptionRecipientEligibility;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.service.NotificationDispatcher;
import org.slf4j.Logger;
@ -33,6 +34,7 @@ public class NotificationEventListener {
private final NotificationDispatcher dispatcher;
private final SkillSubscriptionService skillSubscriptionService;
private final ObjectMapper objectMapper;
private final SubscriptionRecipientEligibility subscriptionEligibility;
public NotificationEventListener(SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
@ -40,7 +42,8 @@ public class NotificationEventListener {
RecipientResolver recipientResolver,
NotificationDispatcher dispatcher,
SkillSubscriptionService skillSubscriptionService,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
SubscriptionRecipientEligibility subscriptionEligibility) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.namespaceRepository = namespaceRepository;
@ -48,6 +51,7 @@ public class NotificationEventListener {
this.dispatcher = dispatcher;
this.skillSubscriptionService = skillSubscriptionService;
this.objectMapper = objectMapper;
this.subscriptionEligibility = subscriptionEligibility;
}
@Async("skillhubEventExecutor")
@ -74,6 +78,8 @@ public class NotificationEventListener {
if (subscribers.isEmpty()) {
return;
}
var namespace = namespaceRepository.findById(skill.getNamespaceId()).orElse(null);
subscribers = subscriptionEligibility.currentRecipients(skill, namespace, subscribers);
String title = "Skill updated: " + skillDisplayName(skill);
Map<String, Object> body = bodyWithSkill(skill);
versionLabel(event.versionId(), body);
@ -96,6 +102,8 @@ public class NotificationEventListener {
if (subscribers.isEmpty()) {
return;
}
var namespace = namespaceRepository.findById(skill.getNamespaceId()).orElse(null);
subscribers = subscriptionEligibility.yankedRecipients(skill, namespace, subscribers, event.wasPublished());
String title = "Skill version yanked: " + skillDisplayName(skill);
Map<String, Object> body = bodyWithSkill(skill);
versionLabel(event.versionId(), body);

View file

@ -2,6 +2,17 @@ package com.iflytek.skillhub.domain.social;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.social.event.SkillSubscribedEvent;
import com.iflytek.skillhub.domain.social.event.SkillUnsubscribedEvent;
import org.junit.jupiter.api.BeforeEach;
@ -13,10 +24,13 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import java.util.Optional;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
@ExtendWith(MockitoExtension.class)
class SkillSubscriptionServiceTest {
@ -24,17 +38,34 @@ class SkillSubscriptionServiceTest {
@Mock private SkillSubscriptionRepository subscriptionRepository;
@Mock private SkillRepository skillRepository;
@Mock private ApplicationEventPublisher eventPublisher;
@Mock private NamespaceRepository namespaceRepository;
@Mock private NamespaceMemberRepository namespaceMemberRepository;
@Mock private UserAccountRepository userAccountRepository;
private SkillSubscriptionService service;
private void allowPublicSubscription(Skill skill) {
skill.setLatestVersionId(10L);
when(userAccountRepository.findById("user-1"))
.thenReturn(Optional.of(new UserAccount("user-1", "User", null, null)));
Namespace namespace = new Namespace("demo", "Demo", "owner");
when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(skill.getNamespaceId(), "user-1"))
.thenReturn(Optional.empty());
}
@BeforeEach
void setUp() {
service = new SkillSubscriptionService(subscriptionRepository, skillRepository, eventPublisher);
service = new SkillSubscriptionService(subscriptionRepository, skillRepository, eventPublisher,
namespaceRepository, namespaceMemberRepository, userAccountRepository,
new SubscriptionMetadataAccessPolicy());
}
@Test
void subscribe_createsSubscriptionAndPublishesEvent() {
when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class)));
Skill skill = new Skill(5L, "public-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
allowPublicSubscription(skill);
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty());
when(subscriptionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@ -50,7 +81,9 @@ class SkillSubscriptionServiceTest {
@Test
void subscribe_idempotent_doesNotDuplicate() {
when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class)));
Skill skill = new Skill(5L, "public-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
allowPublicSubscription(skill);
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1"))
.thenReturn(Optional.of(mock(SkillSubscription.class)));
@ -102,4 +135,120 @@ class SkillSubscriptionServiceTest {
assertThat(service.isSubscribed(1L, "user-1")).isFalse();
}
@Test
void subscribe_rejectsInactiveAccountWithoutMutation() {
Skill skill = new Skill(5L, "private-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PRIVATE);
skill.setLatestVersionId(10L);
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
UserAccount account = new UserAccount("user-1", "User", null, null);
account.setStatus(UserStatus.DISABLED);
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(account));
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.subscribe(1L, "user-1"))
.isInstanceOf(DomainForbiddenException.class);
verifyNoInteractions(subscriptionRepository);
verify(skillRepository, never()).incrementSubscriptionCount(anyLong());
verifyNoInteractions(eventPublisher);
}
@Test
void subscribe_rejectsRemovedMemberOfArchivedNamespaceWithoutMutation() {
Skill skill = new Skill(5L, "public-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
skill.setLatestVersionId(10L);
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(userAccountRepository.findById("user-1"))
.thenReturn(Optional.of(new UserAccount("user-1", "User", null, null)));
Namespace namespace = new Namespace("archived", "Archived", "owner");
namespace.setStatus(NamespaceStatus.ARCHIVED);
when(namespaceRepository.findById(5L)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(5L, "user-1")).thenReturn(Optional.empty());
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.subscribe(1L, "user-1"))
.isInstanceOf(DomainForbiddenException.class);
verifyNoInteractions(subscriptionRepository);
verify(skillRepository, never()).incrementSubscriptionCount(anyLong());
verifyNoInteractions(eventPublisher);
}
static Stream<DeniedSubscription> deniedSubscriptions() {
return Stream.of(
new DeniedSubscription("private member", SkillVisibility.PRIVATE, false, NamespaceStatus.ACTIVE,
NamespaceRole.MEMBER),
new DeniedSubscription("private nonmember", SkillVisibility.PRIVATE, false, NamespaceStatus.ACTIVE,
null),
new DeniedSubscription("private cross namespace member", SkillVisibility.PRIVATE, false,
NamespaceStatus.ACTIVE, null),
new DeniedSubscription("hidden public", SkillVisibility.PUBLIC, true, NamespaceStatus.ACTIVE,
NamespaceRole.MEMBER)
);
}
@ParameterizedTest(name = "{0}")
@MethodSource("deniedSubscriptions")
void subscribe_rejectsUnauthorizedMetadataWithoutAnyMutation(DeniedSubscription scenario) {
Skill skill = new Skill(5L, "restricted", "owner", scenario.visibility());
skill.setLatestVersionId(10L);
skill.setHidden(scenario.hidden());
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(userAccountRepository.findById("user-1"))
.thenReturn(Optional.of(new UserAccount("user-1", "User", null, null)));
Namespace namespace = new Namespace("team", "Team", "owner");
namespace.setStatus(scenario.namespaceStatus());
when(namespaceRepository.findById(5L)).thenReturn(Optional.of(namespace));
if (scenario.role() == null) {
when(namespaceMemberRepository.findByNamespaceIdAndUserId(5L, "user-1"))
.thenReturn(Optional.empty());
} else {
when(namespaceMemberRepository.findByNamespaceIdAndUserId(5L, "user-1"))
.thenReturn(Optional.of(new NamespaceMember(5L, "user-1", scenario.role())));
}
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.subscribe(1L, "user-1"))
.isInstanceOf(DomainForbiddenException.class);
verifyNoInteractions(subscriptionRepository);
verify(skillRepository, never()).incrementSubscriptionCount(anyLong());
verifyNoInteractions(eventPublisher);
}
@Test
void subscribe_allowsPublicArchivedSkillBecauseMetadataPurposeDoesNotRequireActiveSkill() {
Skill skill = new Skill(5L, "archived-skill", "owner", SkillVisibility.PUBLIC);
skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED);
skill.setLatestVersionId(10L);
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(userAccountRepository.findById("user-1"))
.thenReturn(Optional.of(new UserAccount("user-1", "User", null, null)));
when(namespaceRepository.findById(5L)).thenReturn(Optional.of(new Namespace("team", "Team", "owner")));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(5L, "user-1")).thenReturn(Optional.empty());
when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty());
service.subscribe(1L, "user-1");
verify(subscriptionRepository).save(any(SkillSubscription.class));
verify(skillRepository).incrementSubscriptionCount(1L);
verify(eventPublisher).publishEvent(any(SkillSubscribedEvent.class));
}
@Test
void getAndDeleteDoNotConsultMetadataAuthorizationDependencies() {
when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class)));
SkillSubscription existing = mock(SkillSubscription.class);
when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1"))
.thenReturn(Optional.of(existing));
assertThat(service.isSubscribed(1L, "user-1")).isTrue();
service.unsubscribe(1L, "user-1");
verifyNoInteractions(namespaceRepository, namespaceMemberRepository, userAccountRepository);
verify(subscriptionRepository).delete(existing);
}
record DeniedSubscription(String label, SkillVisibility visibility, boolean hidden,
NamespaceStatus namespaceStatus, NamespaceRole role) {
@Override public String toString() { return label; }
}
}

View file

@ -4,10 +4,20 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.event.*;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.social.SkillSubscriptionService;
import com.iflytek.skillhub.domain.social.SubscriptionRecipientEligibility;
import com.iflytek.skillhub.domain.social.SubscriptionMetadataAccessPolicy;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.service.NotificationDispatcher;
import org.junit.jupiter.api.Test;
@ -31,10 +41,21 @@ class NotificationEventListenerTest {
@Mock RecipientResolver recipientResolver;
@Mock NotificationDispatcher dispatcher;
@Mock ObjectMapper objectMapper;
@Mock SkillSubscriptionService skillSubscriptionService;
@Mock UserAccountRepository userAccountRepository;
@Mock NamespaceMemberRepository namespaceMemberRepository;
@InjectMocks
NotificationEventListener listener;
@org.junit.jupiter.api.BeforeEach
void setUpListener() {
listener = new NotificationEventListener(skillRepository, skillVersionRepository, namespaceRepository,
recipientResolver, dispatcher, skillSubscriptionService, objectMapper,
new SubscriptionRecipientEligibility(userAccountRepository, namespaceMemberRepository,
new SubscriptionMetadataAccessPolicy()));
}
private Skill mockSkill(Long id) {
Skill skill = mock(Skill.class);
when(skill.getId()).thenReturn(id);
@ -225,4 +246,153 @@ class NotificationEventListenerTest {
verify(dispatcher).dispatch(eq("reporter-1"), eq(NotificationCategory.REPORT),
eq("REPORT_RESOLVED"), anyString(), anyString(), eq("SKILL"), eq(1L));
}
@Test
void publishSubscriberFanoutExcludesInactiveAccount() {
Skill skill = skill(1L, "owner", "owner");
skill.setLatestVersionId(10L);
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("inactive"));
UserAccount inactive = new UserAccount("inactive", "Inactive", null, null);
inactive.setStatus(UserStatus.DISABLED);
when(userAccountRepository.findByIdIn(List.of("inactive"))).thenReturn(List.of(inactive));
mockNamespace();
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "owner"));
verifyNoInteractions(dispatcher);
}
@Test
void publishSubscriberFanoutFailsClosedBeforeDispatchWhenAccountBatchFails() {
Skill skill = skill(1L, "owner", "owner");
skill.setLatestVersionId(10L);
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("user-1", "user-2"));
when(userAccountRepository.findByIdIn(anyList())).thenThrow(new IllegalStateException("account batch unavailable"));
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "owner")))
.isInstanceOf(IllegalStateException.class);
verifyNoInteractions(dispatcher);
}
@Test
void publishSubscriberFanoutDispatchesOnlyEligibleNonPublisherWithExactPayload() throws Exception {
Skill skill = skill(1L, "publisher", "publisher");
skill.setLatestVersionId(10L);
skill.setVisibility(SkillVisibility.PRIVATE);
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(skillSubscriptionService.findSubscribersBySkillId(1L))
.thenReturn(List.of("publisher", "admin", "member", "inactive", "missing"));
UserAccount publisher = new UserAccount("publisher", "Publisher", null, null);
UserAccount admin = new UserAccount("admin", "Admin", null, null);
UserAccount member = new UserAccount("member", "Member", null, null);
UserAccount inactive = new UserAccount("inactive", "Inactive", null, null);
inactive.setStatus(UserStatus.DISABLED);
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(publisher, admin, member, inactive));
when(namespaceMemberRepository.findByNamespaceIdAndUserIdIn(eq(5L), anyCollection()))
.thenReturn(List.of(new NamespaceMember(5L, "admin", NamespaceRole.ADMIN),
new NamespaceMember(5L, "member", NamespaceRole.MEMBER)));
mockNamespace();
when(objectMapper.writeValueAsString(any())).thenReturn("{\"skillId\":1,\"version\":\"1.0.0\"}");
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "publisher"));
verify(dispatcher).dispatch("admin", NotificationCategory.PUBLISH, "SUBSCRIPTION_NEW_VERSION",
"Skill updated: Test Skill", "{\"skillId\":1,\"version\":\"1.0.0\"}", "SKILL", 1L);
verifyNoMoreInteractions(dispatcher);
}
@Test
void yankWithoutFallbackUsesVerifiedPreYankPublicationAndExcludesActor() throws Exception {
Skill skill = skill(1L, "owner", "owner");
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("actor", "subscriber"));
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(
new UserAccount("actor", "Actor", null, null),
new UserAccount("subscriber", "Subscriber", null, null)));
mockNamespace();
when(objectMapper.writeValueAsString(any())).thenReturn("{\"skillId\":1,\"versionId\":10}");
listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", true));
verify(dispatcher).dispatch("subscriber", NotificationCategory.PUBLISH, "SUBSCRIPTION_VERSION_YANKED",
"Skill version yanked: Test Skill", "{\"skillId\":1,\"versionId\":10}", "SKILL", 1L);
verifyNoMoreInteractions(dispatcher);
}
@Test
void yankDoesNotDispatchWhenEventDoesNotVerifyPublishedPreState() {
Skill skill = skill(1L, "owner", "owner");
skill.setLatestVersionId(9L);
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("subscriber"));
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(
new UserAccount("subscriber", "Subscriber", null, null)));
mockNamespace();
listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", false));
verifyNoInteractions(dispatcher);
}
@Test
void publishFanoutFailsClosedBeforeDispatchWhenNamespaceReadFails() {
Skill skill = skill(1L, "owner", "owner");
skill.setLatestVersionId(10L);
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("user-1"));
when(namespaceRepository.findById(5L)).thenThrow(new IllegalStateException("namespace unavailable"));
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "owner")))
.isInstanceOf(IllegalStateException.class);
verifyNoInteractions(dispatcher);
}
@Test
void yankFanoutFailsClosedBeforeDispatchWhenMembershipBatchFails() {
Skill skill = skill(1L, "owner", "owner");
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("user-1", "user-2"));
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(
new UserAccount("user-1", "One", null, null),
new UserAccount("user-2", "Two", null, null)));
when(namespaceRepository.findById(5L))
.thenReturn(Optional.of(new Namespace("demo", "Demo", "owner")));
when(namespaceMemberRepository.findByNamespaceIdAndUserIdIn(eq(5L), anyCollection()))
.thenThrow(new IllegalStateException("membership unavailable"));
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", true)))
.isInstanceOf(IllegalStateException.class);
verifyNoInteractions(dispatcher);
}
@Test
void archivedNamespaceRemovedSubscriberIsRejectedButCurrentMemberReceivesYank() throws Exception {
Skill skill = skill(1L, "owner", "owner");
skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED);
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("current", "removed"));
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(
new UserAccount("current", "Current", null, null),
new UserAccount("removed", "Removed", null, null)));
Namespace namespace = new Namespace("archived", "Archived", "owner");
namespace.setStatus(NamespaceStatus.ARCHIVED);
when(namespaceRepository.findById(5L)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserIdIn(eq(5L), anyCollection()))
.thenReturn(List.of(new NamespaceMember(5L, "current", NamespaceRole.MEMBER)));
when(objectMapper.writeValueAsString(any())).thenReturn("{}");
listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", true));
verify(dispatcher).dispatch(eq("current"), eq(NotificationCategory.PUBLISH),
eq("SUBSCRIPTION_VERSION_YANKED"), anyString(), eq("{}"), eq("SKILL"), eq(1L));
verifyNoMoreInteractions(dispatcher);
}
}

View file

@ -0,0 +1,293 @@
package com.iflytek.skillhub.listener;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.social.SkillSubscriptionService;
import com.iflytek.skillhub.domain.social.SubscriptionMetadataAccessPolicy;
import com.iflytek.skillhub.domain.social.SubscriptionRecipientEligibility;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
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.service.NotificationDispatcher;
import com.iflytek.skillhub.notification.service.NotificationPreferenceService;
import com.iflytek.skillhub.notification.service.NotificationService;
import com.iflytek.skillhub.notification.sse.SseEmitterManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SubscriberNotificationSinkTest {
private static final Long SKILL_ID = 1L;
private static final Long NAMESPACE_ID = 5L;
private static final Long VERSION_ID = 10L;
private static final Instant CREATED_AT = Instant.parse("2026-08-19T20:30:00Z");
@Mock SkillRepository skillRepository;
@Mock SkillVersionRepository skillVersionRepository;
@Mock NamespaceRepository namespaceRepository;
@Mock RecipientResolver recipientResolver;
@Mock SkillSubscriptionService subscriptionService;
@Mock UserAccountRepository accountRepository;
@Mock NamespaceMemberRepository memberRepository;
@Mock NotificationService notificationService;
@Mock NotificationPreferenceService preferenceService;
@Mock SseEmitterManager sseEmitterManager;
private NotificationEventListener listener;
@BeforeEach
void setUp() {
SubscriptionRecipientEligibility eligibility = new SubscriptionRecipientEligibility(
accountRepository, memberRepository, new SubscriptionMetadataAccessPolicy());
NotificationDispatcher dispatcher = new NotificationDispatcher(
notificationService, preferenceService, sseEmitterManager);
listener = new NotificationEventListener(skillRepository, skillVersionRepository, namespaceRepository,
recipientResolver, dispatcher, subscriptionService, new ObjectMapper(), eligibility);
}
@Test
void publishPersistsAndPushesOnlyCurrentEligibleNonPublisherAcrossAuthorizationMatrix() {
Skill skill = skill(SkillVisibility.PRIVATE, false, VERSION_ID);
Namespace namespace = namespace(NamespaceStatus.ACTIVE);
List<String> candidates = List.of("publisher", "current-admin", "stale-removed", "inactive",
"missing", "private-member", "cross-namespace", "platform-super-admin");
arrangeEvent(skill, namespace, candidates);
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(
account("publisher"), account("current-admin"), account("stale-removed"),
inactiveAccount("inactive"), account("private-member"), account("cross-namespace"),
account("platform-super-admin")));
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of(
member("current-admin", NamespaceRole.ADMIN),
member("private-member", NamespaceRole.MEMBER)));
enablePersistenceFor("current-admin", "SUBSCRIPTION_NEW_VERSION");
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(SKILL_ID, VERSION_ID, "publisher"));
String body = "{\"skillId\":1,\"skillName\":\"Test Skill\",\"slug\":\"test-skill\",\"namespace\":\"demo\"}";
verify(notificationService).create("current-admin", NotificationCategory.PUBLISH,
"SUBSCRIPTION_NEW_VERSION", "Skill updated: Test Skill", body, "SKILL", SKILL_ID);
assertSingleSse("current-admin", "SUBSCRIPTION_NEW_VERSION", body);
verify(accountRepository).findByIdIn(candidates);
verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates);
}
@Test
void hiddenPublishPersistsAndPushesOnlyManagerWhileOrdinaryAndPlatformOnlyCandidatesStayAtZero() {
Skill skill = skill(SkillVisibility.PUBLIC, true, VERSION_ID);
Namespace namespace = namespace(NamespaceStatus.ACTIVE);
List<String> candidates = List.of("manager", "ordinary-member", "platform-super-admin");
arrangeEvent(skill, namespace, candidates);
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(
account("manager"), account("ordinary-member"), account("platform-super-admin")));
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of(
member("manager", NamespaceRole.ADMIN), member("ordinary-member", NamespaceRole.MEMBER)));
enablePersistenceFor("manager", "SUBSCRIPTION_NEW_VERSION");
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(SKILL_ID, VERSION_ID, "publisher"));
String body = "{\"skillId\":1,\"skillName\":\"Test Skill\",\"slug\":\"test-skill\",\"namespace\":\"demo\"}";
verify(notificationService).create("manager", NotificationCategory.PUBLISH,
"SUBSCRIPTION_NEW_VERSION", "Skill updated: Test Skill", body, "SKILL", SKILL_ID);
assertSingleSse("manager", "SUBSCRIPTION_NEW_VERSION", body);
verify(accountRepository).findByIdIn(candidates);
verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates);
}
@ParameterizedTest(name = "yank wasPublished with fallback={0} reaches only current archived-namespace member")
@ValueSource(booleans = {true, false})
void yankPersistsAndPushesOnlyCurrentMemberForFallbackAndNoFallback(boolean hasFallback) {
Skill skill = skill(SkillVisibility.PUBLIC, false, hasFallback ? 9L : null);
Namespace namespace = namespace(NamespaceStatus.ARCHIVED);
List<String> candidates = List.of("actor", "current", "removed", "inactive", "missing");
arrangeEvent(skill, namespace, candidates);
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(
account("actor"), account("current"), account("removed"), inactiveAccount("inactive")));
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of(
member("actor", NamespaceRole.ADMIN), member("current", NamespaceRole.MEMBER)));
enablePersistenceFor("current", "SUBSCRIPTION_VERSION_YANKED");
listener.onSkillVersionYankedForSubscribers(
new SkillVersionYankedEvent(SKILL_ID, VERSION_ID, "actor", true));
String body = "{\"skillId\":1,\"skillName\":\"Test Skill\",\"slug\":\"test-skill\",\"namespace\":\"demo\"}";
verify(notificationService).create("current", NotificationCategory.PUBLISH,
"SUBSCRIPTION_VERSION_YANKED", "Skill version yanked: Test Skill", body, "SKILL", SKILL_ID);
assertSingleSse("current", "SUBSCRIPTION_VERSION_YANKED", body);
verify(accountRepository).findByIdIn(candidates);
verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates);
}
@Test
void yankWithoutVerifiedPublishedPreStateProducesNoPersistenceOrSse() {
Skill skill = skill(SkillVisibility.PUBLIC, false, null);
Namespace namespace = namespace(NamespaceStatus.ACTIVE);
List<String> candidates = List.of("current");
arrangeEvent(skill, namespace, candidates);
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(account("current")));
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of());
listener.onSkillVersionYankedForSubscribers(
new SkillVersionYankedEvent(SKILL_ID, VERSION_ID, "actor", false));
verifyNoInteractions(notificationService, preferenceService, sseEmitterManager);
verify(accountRepository).findByIdIn(candidates);
verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates);
}
@ParameterizedTest(name = "{0} batch failure happens before every final sink")
@EnumSource(BatchFailure.class)
void authoritativeBatchFailureProducesNoPartialPersistenceOrSse(BatchFailure failure) {
Skill skill = skill(SkillVisibility.PUBLIC, false, VERSION_ID);
Namespace namespace = namespace(NamespaceStatus.ACTIVE);
List<String> candidates = List.of("first", "second");
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(subscriptionService.findSubscribersBySkillId(SKILL_ID)).thenReturn(candidates);
if (failure == BatchFailure.NAMESPACE) {
when(namespaceRepository.findById(NAMESPACE_ID)).thenThrow(new IllegalStateException("namespace batch"));
} else {
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace));
if (failure == BatchFailure.ACCOUNT) {
when(accountRepository.findByIdIn(candidates)).thenThrow(new IllegalStateException("account batch"));
} else {
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(account("first"), account("second")));
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates))
.thenThrow(new IllegalStateException("membership batch"));
}
}
assertThatThrownBy(() -> listener.onSkillPublishedForSubscribers(
new SkillPublishedEvent(SKILL_ID, VERSION_ID, "publisher")))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining(failure.name().toLowerCase());
verifyNoInteractions(notificationService, preferenceService, sseEmitterManager);
verify(namespaceRepository, times(1)).findById(NAMESPACE_ID);
if (failure == BatchFailure.NAMESPACE) {
verify(accountRepository, never()).findByIdIn(anyList());
verify(memberRepository, never()).findByNamespaceIdAndUserIdIn(any(), anyCollection());
} else {
verify(accountRepository, times(1)).findByIdIn(candidates);
verify(memberRepository, failure == BatchFailure.MEMBERSHIP ? times(1) : never())
.findByNamespaceIdAndUserIdIn(eq(NAMESPACE_ID), anyCollection());
}
}
private void arrangeEvent(Skill skill, Namespace namespace, List<String> candidates) {
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(subscriptionService.findSubscribersBySkillId(SKILL_ID)).thenReturn(candidates);
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace));
}
private void enablePersistenceFor(String recipient, String eventType) {
when(preferenceService.isEnabled(recipient, NotificationCategory.PUBLISH, NotificationChannel.IN_APP))
.thenReturn(true);
when(notificationService.create(eq(recipient), eq(NotificationCategory.PUBLISH), eq(eventType),
any(String.class), any(String.class), eq("SKILL"), eq(SKILL_ID)))
.thenAnswer(invocation -> notification(recipient, eventType,
invocation.getArgument(3), invocation.getArgument(4)));
}
private void assertSingleSse(String recipient, String eventType, String body) {
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> payload = ArgumentCaptor.forClass(Map.class);
verify(sseEmitterManager).push(eq(recipient), payload.capture());
assertThat(payload.getValue())
.containsEntry("id", 42L)
.containsEntry("category", "PUBLISH")
.containsEntry("eventType", eventType)
.containsEntry("bodyJson", body)
.containsEntry("entityType", "SKILL")
.containsEntry("entityId", SKILL_ID);
}
private Skill skill(SkillVisibility visibility, boolean hidden, Long latestVersionId) {
Skill skill = new Skill(NAMESPACE_ID, "test-skill", "publisher", visibility);
skill.setDisplayName("Test Skill");
skill.setHidden(hidden);
skill.setLatestVersionId(latestVersionId);
setId(skill, SKILL_ID);
return skill;
}
private Namespace namespace(NamespaceStatus status) {
Namespace namespace = new Namespace("demo", "Demo", "publisher");
namespace.setStatus(status);
return namespace;
}
private UserAccount account(String id) {
return new UserAccount(id, id, null, null);
}
private UserAccount inactiveAccount(String id) {
UserAccount account = account(id);
account.setStatus(UserStatus.DISABLED);
return account;
}
private NamespaceMember member(String userId, NamespaceRole role) {
return new NamespaceMember(NAMESPACE_ID, userId, role);
}
private Notification notification(String recipient, String eventType, String title, String body) {
Notification notification = new Notification(recipient, NotificationCategory.PUBLISH, eventType,
title, body, "SKILL", SKILL_ID, CREATED_AT);
setId(notification, 42L);
return notification;
}
private void setId(Object entity, Long id) {
try {
var field = entity.getClass().getDeclaredField("id");
field.setAccessible(true);
field.set(entity, id);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
}
private enum BatchFailure {
ACCOUNT,
NAMESPACE,
MEMBERSHIP
}
}

View file

@ -1,3 +1,3 @@
package com.iflytek.skillhub.domain.event;
public record SkillVersionYankedEvent(Long skillId, Long versionId, String actorUserId) {}
public record SkillVersionYankedEvent(Long skillId, Long versionId, String actorUserId, boolean wasPublished) {}

View file

@ -15,6 +15,7 @@ public interface NamespaceMemberRepository {
List<NamespaceMember> findByUserId(String userId);
Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable);
List<NamespaceMember> findByNamespaceIdAndRoleIn(Long namespaceId, Collection<NamespaceRole> roles);
List<NamespaceMember> findByNamespaceIdAndUserIdIn(Long namespaceId, Collection<String> userIds);
NamespaceMember save(NamespaceMember member);
void deleteByNamespaceId(Long namespaceId);
void deleteByNamespaceIdAndUserId(Long namespaceId, String userId);

View file

@ -281,7 +281,7 @@ public class SkillGovernanceService {
});
auditLogService.record(actorUserId, "YANK_SKILL_VERSION", "SKILL_VERSION", versionId, null, clientIp, userAgent, jsonReason(reason));
eventPublisher.publishEvent(new com.iflytek.skillhub.domain.event.SkillVersionYankedEvent(
version.getSkillId(), versionId, actorUserId));
version.getSkillId(), versionId, actorUserId, true));
return saved;
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewTask;
@ -145,6 +146,9 @@ public class SkillReviewSubmitService {
skill.setLatestVersionId(versionId);
skill.setUpdatedBy(actorUserId);
skillRepository.save(skill);
eventPublisher.publishEvent(new SkillPublishedEvent(
skill.getId(), version.getId(), actorUserId));
}
private void assertCanManageLifecycle(Skill skill, String actorUserId, Map<Long, NamespaceRole> userNamespaceRoles) {

View file

@ -2,6 +2,10 @@ package com.iflytek.skillhub.domain.social;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.namespace.*;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.social.event.SkillSubscribedEvent;
import com.iflytek.skillhub.domain.social.event.SkillUnsubscribedEvent;
import org.springframework.context.ApplicationEventPublisher;
@ -9,24 +13,46 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
@Service
public class SkillSubscriptionService {
private final SkillSubscriptionRepository subscriptionRepository;
private final SkillRepository skillRepository;
private final ApplicationEventPublisher eventPublisher;
private final NamespaceRepository namespaceRepository;
private final NamespaceMemberRepository memberRepository;
private final UserAccountRepository accountRepository;
private final SubscriptionMetadataAccessPolicy accessPolicy;
public SkillSubscriptionService(SkillSubscriptionRepository subscriptionRepository,
SkillRepository skillRepository,
ApplicationEventPublisher eventPublisher) {
ApplicationEventPublisher eventPublisher,
NamespaceRepository namespaceRepository,
NamespaceMemberRepository memberRepository,
UserAccountRepository accountRepository,
SubscriptionMetadataAccessPolicy accessPolicy) {
this.subscriptionRepository = subscriptionRepository;
this.skillRepository = skillRepository;
this.eventPublisher = eventPublisher;
this.namespaceRepository = namespaceRepository;
this.memberRepository = memberRepository;
this.accountRepository = accountRepository;
this.accessPolicy = accessPolicy;
}
@Transactional
public void subscribe(Long skillId, String userId) {
ensureSkillExists(skillId);
var skill = skillRepository.findById(skillId)
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillId));
UserAccount account = accountRepository.findById(userId).orElse(null);
Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()).orElse(null);
var role = memberRepository.findByNamespaceIdAndUserId(skill.getNamespaceId(), userId)
.map(NamespaceMember::getRole);
Map<Long, NamespaceRole> roles = role.map(value -> Map.of(skill.getNamespaceId(), value)).orElse(Map.of());
if (!accessPolicy.canAccessCurrent(skill, namespace, account, roles)) {
throw new DomainForbiddenException("error.skill.subscription.noPermission");
}
if (subscriptionRepository.findBySkillIdAndUserId(skillId, userId).isPresent()) {
return; // idempotent
}

View file

@ -0,0 +1,52 @@
package com.iflytek.skillhub.domain.social;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.user.UserAccount;
import java.util.Map;
import org.springframework.stereotype.Component;
/** Authorization for metadata exposed by subscriptions and subscriber notifications. */
@Component
public class SubscriptionMetadataAccessPolicy {
public boolean canAccessCurrent(Skill skill, Namespace namespace, UserAccount account,
Map<Long, NamespaceRole> namespaceRoles) {
return canAccess(skill, namespace, account, namespaceRoles, false);
}
public boolean canAccessYankedPublication(Skill skill, Namespace namespace, UserAccount account,
Map<Long, NamespaceRole> namespaceRoles,
boolean wasPublished) {
return wasPublished && canAccess(skill, namespace, account, namespaceRoles, true);
}
private boolean canAccess(Skill skill, Namespace namespace, UserAccount account,
Map<Long, NamespaceRole> namespaceRoles, boolean yankedPublication) {
if (account == null || !account.isActive() || namespace == null) {
return false;
}
Map<Long, NamespaceRole> roles = namespaceRoles == null ? Map.of() : namespaceRoles;
NamespaceRole role = roles.get(skill.getNamespaceId());
if (namespace.getStatus() == NamespaceStatus.ARCHIVED && role == null) {
return false;
}
boolean owner = skill.getOwnerId().equals(account.getId());
boolean manager = role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER;
if (skill.isHidden()) {
return owner || manager;
}
if (!yankedPublication && skill.getLatestVersionId() == null) {
return owner;
}
return switch (skill.getVisibility()) {
case PUBLIC -> true;
case NAMESPACE_ONLY -> role != null;
case PRIVATE -> owner || manager;
};
}
}

View file

@ -0,0 +1,53 @@
package com.iflytek.skillhub.domain.social;
import com.iflytek.skillhub.domain.namespace.*;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import java.util.*;
import java.util.function.BiPredicate;
import org.springframework.stereotype.Component;
/** Loads authoritative account and membership facts in batches before selecting recipients. */
@Component
public class SubscriptionRecipientEligibility {
private final UserAccountRepository accountRepository;
private final NamespaceMemberRepository memberRepository;
private final SubscriptionMetadataAccessPolicy policy;
public SubscriptionRecipientEligibility(UserAccountRepository accountRepository,
NamespaceMemberRepository memberRepository,
SubscriptionMetadataAccessPolicy policy) {
this.accountRepository = accountRepository;
this.memberRepository = memberRepository;
this.policy = policy;
}
public List<String> currentRecipients(Skill skill, Namespace namespace, List<String> candidateIds) {
return eligible(skill, namespace, candidateIds,
(account, roles) -> policy.canAccessCurrent(skill, namespace, account, roles));
}
public List<String> yankedRecipients(Skill skill, Namespace namespace, List<String> candidateIds,
boolean wasPublished) {
return eligible(skill, namespace, candidateIds,
(account, roles) -> policy.canAccessYankedPublication(skill, namespace, account, roles, wasPublished));
}
private List<String> eligible(Skill skill, Namespace namespace, List<String> candidateIds,
BiPredicate<UserAccount, Map<Long, NamespaceRole>> predicate) {
List<String> ids = candidateIds.stream().distinct().toList();
if (ids.isEmpty()) return List.of();
Map<String, UserAccount> accounts = new HashMap<>();
accountRepository.findByIdIn(ids).forEach(account -> accounts.put(account.getId(), account));
Map<String, NamespaceRole> roles = new HashMap<>();
memberRepository.findByNamespaceIdAndUserIdIn(skill.getNamespaceId(), ids)
.forEach(member -> roles.put(member.getUserId(), member.getRole()));
return ids.stream().filter(id -> {
NamespaceRole role = roles.get(id);
Map<Long, NamespaceRole> roleMap = role == null ? Map.of() : Map.of(skill.getNamespaceId(), role);
return predicate.test(accounts.get(id), roleMap);
}).toList();
}
}

View file

@ -214,6 +214,25 @@ class SkillDownloadServiceTest {
verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class));
}
@Test
void downloadRejectsPublicArchivedSkillEvenThoughItsMetadataRemainsReadable() throws Exception {
Namespace namespace = new Namespace("global", "Global", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, "archived-skill", "owner-1", SkillVisibility.PUBLIC);
setId(skill, 9L);
skill.setStatus(SkillStatus.ARCHIVED);
skill.setLatestVersionId(10L);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, "archived-skill")).thenReturn(List.of(skill));
assertThrows(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class, () ->
service.downloadLatest("global", "archived-skill", "viewer", Map.of()));
verify(skillRepository, never()).incrementDownloadCount(anyLong());
verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong());
verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class));
}
@Test
void testDownloadLatest_ShouldRejectAnonymousHiddenPrivateAndUnpublishedSkills() throws Exception {
Namespace namespace = new Namespace("global", "Global", "owner-1");

View file

@ -202,6 +202,64 @@ class SkillQueryServiceTest {
service.getSkillDetail(namespaceSlug, skillSlug, null, Map.of()));
}
@Test
void getSkillDetailKeepsPublicArchivedSkillDirectlyReadableWithArchivedStatus() throws Exception {
Namespace namespace = new Namespace("global", "Global", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, "archived-skill", "owner-1", SkillVisibility.PUBLIC);
setId(skill, 9L);
skill.setStatus(SkillStatus.ARCHIVED);
skill.setLatestVersionId(10L);
SkillVersion version = new SkillVersion(9L, "1.0.0", "owner-1");
setId(version, 10L);
version.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, "archived-skill")).thenReturn(List.of(skill));
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
when(userAccountRepository.findById("owner-1"))
.thenReturn(Optional.of(new UserAccount("owner-1", "Owner", null, null)));
SkillQueryService.SkillDetailDTO detail =
service.getSkillDetail("global", "archived-skill", "viewer", Map.of());
assertEquals("ARCHIVED", detail.status());
assertEquals("archived-skill", detail.slug());
}
@Test
void namespaceDiscoveryQueriesOnlyActiveSkillsSoArchivedSkillIsAbsent() throws Exception {
Namespace namespace = new Namespace("global", "Global", "owner-1");
setId(namespace, 1L);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)).thenReturn(List.of());
Page<Skill> result = service.listSkillsByNamespace("global", "viewer", Map.of(), PageRequest.of(0, 20));
assertTrue(result.isEmpty());
verify(skillRepository).findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE);
}
@Test
void versionContentRejectsPublicArchivedSkillForNonManager() throws Exception {
Namespace namespace = new Namespace("global", "Global", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, "archived-skill", "owner-1", SkillVisibility.PUBLIC);
setId(skill, 9L);
skill.setStatus(SkillStatus.ARCHIVED);
skill.setLatestVersionId(10L);
SkillVersion version = new SkillVersion(9L, "1.0.0", "owner-1");
setId(version, 10L);
version.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, "archived-skill")).thenReturn(List.of(skill));
lenient().when(skillVersionRepository.findBySkillIdAndVersion(9L, "1.0.0"))
.thenReturn(Optional.of(version));
assertThrows(DomainForbiddenException.class, () ->
service.listFiles("global", "archived-skill", "1.0.0", "viewer", Map.of()));
verifyNoInteractions(skillFileRepository);
}
@Test
void testListSkillsByNamespace() throws Exception {
// Arrange

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
@ -85,6 +86,7 @@ class SkillReviewSubmitServiceTest {
assertEquals(SkillVersionStatus.PENDING_REVIEW, version.getStatus());
assertEquals(SkillVisibility.PUBLIC, version.getRequestedVisibility());
verify(reviewTaskRepository).save(any(ReviewTask.class));
verify(eventPublisher, never()).publishEvent(any());
}
@Test
@ -180,7 +182,16 @@ class SkillReviewSubmitServiceTest {
assertEquals(SkillVersionStatus.PUBLISHED, version.getStatus());
assertNotNull(version.getPublishedAt());
assertEquals(versionId, skill.getLatestVersionId());
verify(skillVersionRepository).save(version);
verify(skillRepository).save(skill);
ArgumentCaptor<SkillPublishedEvent> eventCaptor =
ArgumentCaptor.forClass(SkillPublishedEvent.class);
verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture());
SkillPublishedEvent event = eventCaptor.getValue();
assertEquals(skillId, event.skillId());
assertEquals(versionId, event.versionId());
assertEquals(userId, event.publisherId());
}
@Test
@ -225,6 +236,7 @@ class SkillReviewSubmitServiceTest {
// When/Then
assertThrows(DomainBadRequestException.class,
() -> service.confirmPublish(skillId, versionId, userId, Map.of()));
verify(eventPublisher, never()).publishEvent(any());
}
@Test
@ -244,6 +256,55 @@ class SkillReviewSubmitServiceTest {
// When/Then
assertThrows(DomainBadRequestException.class,
() -> service.confirmPublish(skillId, versionId, userId, Map.of()));
verify(eventPublisher, never()).publishEvent(any());
}
@Test
@DisplayName("should not publish event when version persistence fails")
void shouldNotPublishEventWhenVersionPersistenceFails() {
// Given
Long skillId = 1L;
Long versionId = 100L;
String userId = "user-1";
Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE);
SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED);
RuntimeException persistenceFailure = new RuntimeException("version save failed");
when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
when(skillVersionRepository.save(version)).thenThrow(persistenceFailure);
// When/Then
RuntimeException thrown = assertThrows(RuntimeException.class,
() -> service.confirmPublish(skillId, versionId, userId, Map.of()));
assertSame(persistenceFailure, thrown);
verify(skillRepository, never()).save(any());
verify(eventPublisher, never()).publishEvent(any());
}
@Test
@DisplayName("should not publish event when skill persistence fails")
void shouldNotPublishEventWhenSkillPersistenceFails() {
// Given
Long skillId = 1L;
Long versionId = 100L;
String userId = "user-1";
Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE);
SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED);
RuntimeException persistenceFailure = new RuntimeException("skill save failed");
when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
when(skillRepository.save(skill)).thenThrow(persistenceFailure);
// When/Then
RuntimeException thrown = assertThrows(RuntimeException.class,
() -> service.confirmPublish(skillId, versionId, userId, Map.of()));
assertSame(persistenceFailure, thrown);
verify(skillVersionRepository).save(version);
verify(eventPublisher, never()).publishEvent(any());
}
}

View file

@ -22,6 +22,7 @@ public interface NamespaceMemberJpaRepository
List<NamespaceMember> findByUserId(String userId);
Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable);
List<NamespaceMember> findByNamespaceIdAndRoleIn(Long namespaceId, Collection<NamespaceRole> roles);
List<NamespaceMember> findByNamespaceIdAndUserIdIn(Long namespaceId, Collection<String> userIds);
void deleteByNamespaceId(Long namespaceId);
void deleteByNamespaceIdAndUserId(Long namespaceId, String userId);
}

View file

@ -11,6 +11,11 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.util.Map;
import org.mockito.ArgumentCaptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ -58,6 +63,41 @@ class NotificationDispatcherTest {
verify(sseEmitterManager).push(eq("user-1"), any());
}
@Test
void dispatch_persistsExactSubscriberNotificationAndPushesSameRecipientVisiblePayload() {
Notification notification = new Notification("subscriber-1", NotificationCategory.PUBLISH,
"SUBSCRIPTION_NEW_VERSION", "Skill updated: Demo",
"{\"skillId\":1,\"versionId\":10}", "SKILL", 1L,
Instant.parse("2026-08-19T20:30:00Z"));
try {
var id = Notification.class.getDeclaredField("id");
id.setAccessible(true);
id.set(notification, 42L);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
when(preferenceService.isEnabled("subscriber-1", NotificationCategory.PUBLISH,
NotificationChannel.IN_APP)).thenReturn(true);
when(notificationService.create(any(), any(), any(), any(), any(), any(), any()))
.thenReturn(notification);
dispatcher.dispatch("subscriber-1", NotificationCategory.PUBLISH, "SUBSCRIPTION_NEW_VERSION",
"Skill updated: Demo", "{\"skillId\":1,\"versionId\":10}", "SKILL", 1L);
verify(notificationService).create("subscriber-1", NotificationCategory.PUBLISH,
"SUBSCRIPTION_NEW_VERSION", "Skill updated: Demo",
"{\"skillId\":1,\"versionId\":10}", "SKILL", 1L);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> payload = ArgumentCaptor.forClass(Map.class);
verify(sseEmitterManager).push(eq("subscriber-1"), payload.capture());
assertThat(payload.getValue()).containsEntry("id", 42L)
.containsEntry("category", "PUBLISH")
.containsEntry("eventType", "SUBSCRIPTION_NEW_VERSION")
.containsEntry("bodyJson", "{\"skillId\":1,\"versionId\":10}")
.containsEntry("entityType", "SKILL")
.containsEntry("entityId", 1L);
}
@Test
void dispatch_shouldSkipWhenPreferenceDisabled() {
when(preferenceService.isEnabled("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP))