mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
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:
parent
d2403bb591
commit
e071afb4f0
16 changed files with 943 additions and 8 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue