fix(cli): require installable latest in search

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-12 15:12:13 +08:00
parent 32cc316b30
commit f5259daa94
6 changed files with 416 additions and 27 deletions

View file

@ -24,11 +24,13 @@ import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
@ -98,8 +100,6 @@ class SkillSearchAppServiceTest {
when(skillRepository.findByIdIn(List.of(11L))).thenReturn(List.of(visibleSkill));
when(namespaceRepository.findByIdIn(List.of(2L))).thenReturn(List.of(activeNamespace));
when(skillVersionRepository.findByIdIn(List.of(111L))).thenReturn(List.of());
when(skillVersionRepository.findBySkillIdInAndStatus(List.of(11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED))
.thenReturn(List.of());
SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 1, null, null);
@ -147,8 +147,6 @@ class SkillSearchAppServiceTest {
when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(visibleSkill));
when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));
when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of());
when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED))
.thenReturn(List.of());
SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 20, "user-9", Map.of());
@ -166,6 +164,9 @@ class SkillSearchAppServiceTest {
setField(second, "id", 11L);
second.setLatestVersionId(102L);
SkillVersion firstVersion = publishedVersion(10L, 101L, "1.0.0");
SkillVersion secondVersion = publishedVersion(11L, 102L, "2.0.0");
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
setField(namespace, "id", 1L);
namespace.setStatus(NamespaceStatus.ACTIVE);
@ -174,20 +175,80 @@ class SkillSearchAppServiceTest {
.thenReturn(new SearchResult(List.of(10L, 11L), 2, 0, 20));
when(skillRepository.findByIdIn(List.of(10L, 11L))).thenReturn(List.of(first, second));
when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));
when(skillVersionRepository.findByIdIn(List.of(101L, 102L))).thenReturn(List.of());
when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED))
.thenReturn(List.of());
when(skillVersionRepository.findByIdIn(List.of(101L, 102L))).thenReturn(List.of(firstVersion, secondVersion));
SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null);
assertEquals(2, response.items().size());
assertEquals("1.0.0", response.items().get(0).publishedVersion().version());
assertEquals("2.0.0", response.items().get(1).publishedVersion().version());
verify(skillVersionRepository, times(1)).findByIdIn(List.of(101L, 102L));
verify(skillVersionRepository, times(1))
verify(skillVersionRepository, times(0))
.findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED);
}
@Test
void search_shouldNotExposeDownloadUnavailableVersionAsPublishedSummary() {
void search_shouldNotFallbackToOlderPublishedVersionWhenLatestIsMissing() {
Skill skill = new Skill(1L, "missing-latest", "owner-1", SkillVisibility.PUBLIC);
setField(skill, "id", 10L);
SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0");
Namespace namespace = new Namespace("global", "Global", "owner-1");
setField(namespace, "id", 1L);
namespace.setStatus(NamespaceStatus.ACTIVE);
when(searchQueryService.search(any()))
.thenReturn(new SearchResult(List.of(10L), 1, 0, 20));
when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill));
when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));
org.mockito.Mockito.lenient()
.when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED))
.thenReturn(List.of(oldInstallable));
SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null);
assertEquals(1, response.items().size());
assertEquals("missing-latest", response.items().getFirst().slug());
assertNull(response.items().getFirst().publishedVersion());
verify(skillVersionRepository, times(0))
.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED);
}
@Test
void search_shouldNotFallbackToOlderPublishedVersionWhenLatestIsYanked() {
Skill skill = new Skill(1L, "yanked-latest", "owner-1", SkillVisibility.PUBLIC);
setField(skill, "id", 10L);
skill.setLatestVersionId(101L);
SkillVersion latest = publishedVersion(10L, 101L, "1.0.0");
latest.setYankedAt(Instant.parse("2026-06-12T00:00:00Z"));
SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0");
Namespace namespace = new Namespace("global", "Global", "owner-1");
setField(namespace, "id", 1L);
namespace.setStatus(NamespaceStatus.ACTIVE);
when(searchQueryService.search(any()))
.thenReturn(new SearchResult(List.of(10L), 1, 0, 20));
when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill));
when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));
when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(latest));
org.mockito.Mockito.lenient()
.when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED))
.thenReturn(List.of(oldInstallable));
SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null);
assertEquals(1, response.items().size());
assertEquals("yanked-latest", response.items().getFirst().slug());
assertNull(response.items().getFirst().publishedVersion());
verify(skillVersionRepository, times(0))
.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED);
}
@Test
void search_shouldNotFallbackToOlderPublishedVersionWhenLatestDownloadUnavailable() {
Skill skill = new Skill(1L, "not-ready", "owner-1", SkillVisibility.PUBLIC);
setField(skill, "id", 10L);
skill.setLatestVersionId(101L);
@ -196,6 +257,7 @@ class SkillSearchAppServiceTest {
setField(version, "id", 101L);
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setDownloadReady(false);
SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0");
Namespace namespace = new Namespace("global", "Global", "owner-1");
setField(namespace, "id", 1L);
@ -206,14 +268,17 @@ class SkillSearchAppServiceTest {
when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill));
when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));
when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(version));
when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED))
.thenReturn(List.of(version));
org.mockito.Mockito.lenient()
.when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED))
.thenReturn(List.of(oldInstallable));
SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null);
assertEquals(1, response.items().size());
assertEquals("not-ready", response.items().getFirst().slug());
assertEquals(null, response.items().getFirst().publishedVersion());
assertNull(response.items().getFirst().publishedVersion());
verify(skillVersionRepository, times(0))
.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED);
}
@Test
@ -272,4 +337,12 @@ class SkillSearchAppServiceTest {
throw new RuntimeException(e);
}
}
private SkillVersion publishedVersion(Long skillId, Long versionId, String versionNumber) {
SkillVersion version = new SkillVersion(skillId, versionNumber, "owner-1");
setField(version, "id", versionId);
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setDownloadReady(true);
return version;
}
}

View file

@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.event.SkillDownloadedEvent;
import com.iflytek.skillhub.domain.namespace.Namespace;
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.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.*;
@ -284,12 +285,20 @@ public class SkillDownloadService {
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug());
}
if (namespace.getStatus() == NamespaceStatus.ARCHIVED
&& !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) {
throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug());
}
}
private boolean isAnonymousDownloadAllowed(Skill skill) {
return skill.getVisibility() == SkillVisibility.PUBLIC;
}
private boolean isNamespaceMember(Long namespaceId, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
return currentUserId != null && userNsRoles != null && userNsRoles.containsKey(namespaceId);
}
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
return skillSlugResolutionService.resolve(
namespaceId,

View file

@ -89,21 +89,10 @@ public class SkillLifecycleProjectionService {
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
Map<Long, SkillVersion> publishedBySkillId = new java.util.HashMap<>();
List<Long> unresolvedSkillIds = new java.util.ArrayList<>();
for (Skill skill : skills) {
SkillVersion latestVersion = latestVersionsById.get(skill.getLatestVersionId());
if (SkillInstallability.isInstallableVersion(latestVersion)) {
publishedBySkillId.put(skill.getId(), latestVersion);
} else {
unresolvedSkillIds.add(skill.getId());
}
}
if (!unresolvedSkillIds.isEmpty()) {
for (SkillVersion version : skillVersionRepository.findBySkillIdInAndStatus(unresolvedSkillIds, SkillVersionStatus.PUBLISHED)) {
if (SkillInstallability.isInstallableVersion(version)) {
publishedBySkillId.merge(version.getSkillId(), version, this::newerVersion);
}
}
}
@ -160,10 +149,6 @@ public class SkillLifecycleProjectionService {
.thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder()));
}
private SkillVersion newerVersion(SkillVersion left, SkillVersion right) {
return versionComparator().compare(left, right) >= 0 ? left : right;
}
private VersionProjection toProjection(SkillVersion version) {
if (version == null) {
return null;

View file

@ -119,6 +119,137 @@ class SkillDownloadServiceTest {
verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class));
}
@Test
void testDownloadLatest_ShouldRejectSkillWithoutLatest() throws Exception {
String namespaceSlug = "global";
String skillSlug = "missing-latest";
Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () ->
service.downloadLatest(namespaceSlug, skillSlug, null, Map.of()));
assertEquals("error.skill.notFound", ex.messageCode());
assertArrayEquals(new Object[]{skillSlug}, ex.messageArgs());
verify(skillRepository, never()).incrementDownloadCount(anyLong());
verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong());
verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class));
}
@Test
void testDownloadLatest_ShouldRejectYankedLatestVersion() throws Exception {
String namespaceSlug = "global";
String skillSlug = "yanked-latest";
Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
skill.setLatestVersionId(10L);
SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1");
setId(version, 10L);
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setDownloadReady(true);
version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z"));
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true);
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () ->
service.downloadLatest(namespaceSlug, skillSlug, null, Map.of()));
assertEquals("error.skill.version.notDownloadable", ex.messageCode());
assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs());
verify(skillRepository, never()).incrementDownloadCount(anyLong());
verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong());
verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class));
}
@Test
void testDownloadLatest_ShouldRejectAnonymousArchivedNamespaceSkill() throws Exception {
String namespaceSlug = "archived";
String skillSlug = "archived-skill";
Namespace namespace = new Namespace(namespaceSlug, "Archived", "owner-1");
setId(namespace, 1L);
namespace.setStatus(com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED);
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
skill.setLatestVersionId(10L);
SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1");
setId(version, 10L);
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setDownloadReady(true);
ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true);
org.mockito.Mockito.lenient().when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
org.mockito.Mockito.lenient().when(objectStorageService.exists("packages/1/10/bundle.zip")).thenReturn(true);
org.mockito.Mockito.lenient().when(objectStorageService.getMetadata("packages/1/10/bundle.zip")).thenReturn(metadata);
org.mockito.Mockito.lenient().when(objectStorageService.getObject("packages/1/10/bundle.zip"))
.thenReturn(new ByteArrayInputStream("test".getBytes()));
org.mockito.Mockito.lenient()
.when(objectStorageService.generatePresignedUrl(eq("packages/1/10/bundle.zip"), any(), eq("archived-skill-1.0.0.zip")))
.thenReturn(null);
assertThrows(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class, () ->
service.downloadLatest(namespaceSlug, skillSlug, null, 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");
setId(namespace, 1L);
Skill hiddenSkill = new Skill(1L, "hidden", "owner-1", SkillVisibility.PUBLIC);
setId(hiddenSkill, 11L);
hiddenSkill.setStatus(SkillStatus.ACTIVE);
hiddenSkill.setLatestVersionId(101L);
hiddenSkill.setHidden(true);
Skill privateSkill = new Skill(1L, "private", "owner-1", SkillVisibility.PRIVATE);
setId(privateSkill, 12L);
privateSkill.setStatus(SkillStatus.ACTIVE);
privateSkill.setLatestVersionId(102L);
Skill unpublishedSkill = new Skill(1L, "unpublished", "owner-1", SkillVisibility.PUBLIC);
setId(unpublishedSkill, 13L);
unpublishedSkill.setStatus(SkillStatus.ACTIVE);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, "hidden")).thenReturn(List.of(hiddenSkill));
when(skillRepository.findByNamespaceIdAndSlug(1L, "private")).thenReturn(List.of(privateSkill));
when(skillRepository.findByNamespaceIdAndSlug(1L, "unpublished")).thenReturn(List.of(unpublishedSkill));
assertThrows(DomainBadRequestException.class, () ->
service.downloadLatest("global", "hidden", null, Map.of()));
assertThrows(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class, () ->
service.downloadLatest("global", "private", null, Map.of()));
assertThrows(DomainBadRequestException.class, () ->
service.downloadLatest("global", "unpublished", null, Map.of()));
verify(skillRepository, never()).incrementDownloadCount(anyLong());
verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong());
verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class));
}
@Test
void testDownloadByTag_Success() throws Exception {
// Arrange

View file

@ -676,6 +676,160 @@ class SkillQueryServiceTest {
assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs());
}
@Test
void testResolveVersion_ShouldRejectSkillWithoutLatest() throws Exception {
String namespaceSlug = "global";
String skillSlug = "missing-latest";
Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
setId(skill, 3L);
skill.setStatus(SkillStatus.ACTIVE);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () ->
service.resolveVersion(namespaceSlug, skillSlug, null, null, null, null, Map.of()));
assertEquals("error.skill.notFound", ex.messageCode());
assertArrayEquals(new Object[]{skillSlug}, ex.messageArgs());
}
@Test
void testResolveVersion_ShouldRejectYankedLatestVersion() throws Exception {
String namespaceSlug = "global";
String skillSlug = "yanked-latest";
Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
setId(skill, 3L);
skill.setStatus(SkillStatus.ACTIVE);
skill.setLatestVersionId(11L);
SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1");
setId(version, 11L);
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setDownloadReady(true);
version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z"));
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version));
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () ->
service.resolveVersion(namespaceSlug, skillSlug, null, null, null, null, Map.of()));
assertEquals("error.skill.version.notDownloadable", ex.messageCode());
assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs());
}
@Test
void testResolveVersion_ShouldRejectDownloadUnavailableExplicitVersion() throws Exception {
String namespaceSlug = "global";
String skillSlug = "explicit-not-ready";
Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
setId(skill, 3L);
skill.setStatus(SkillStatus.ACTIVE);
skill.setLatestVersionId(11L);
SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1");
setId(version, 11L);
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setDownloadReady(false);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(3L, "1.0.0")).thenReturn(Optional.of(version));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () ->
service.resolveVersion(namespaceSlug, skillSlug, "1.0.0", null, null, null, Map.of()));
assertEquals("error.skill.version.notDownloadable", ex.messageCode());
assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs());
}
@Test
void testResolveVersion_ShouldRejectDownloadUnavailableTaggedVersion() throws Exception {
String namespaceSlug = "global";
String skillSlug = "tag-not-ready";
Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
setId(skill, 3L);
skill.setStatus(SkillStatus.ACTIVE);
skill.setLatestVersionId(11L);
SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1");
setId(version, 11L);
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setDownloadReady(false);
SkillTag tag = new SkillTag(3L, "stable", 11L, "owner-1");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(skillTagRepository.findBySkillIdAndTagName(3L, "stable")).thenReturn(Optional.of(tag));
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () ->
service.resolveVersion(namespaceSlug, skillSlug, null, "stable", null, null, Map.of()));
assertEquals("error.skill.version.notDownloadable", ex.messageCode());
assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs());
}
@Test
void testResolveVersion_ShouldRejectAnonymousHiddenPrivateArchivedAndUnpublishedSkills() throws Exception {
Namespace activeNamespace = new Namespace("global", "Global", "owner-1");
setId(activeNamespace, 1L);
Namespace archivedNamespace = new Namespace("archived", "Archived", "owner-1");
setId(archivedNamespace, 2L);
archivedNamespace.setStatus(NamespaceStatus.ARCHIVED);
Skill hiddenSkill = new Skill(1L, "hidden", "owner-1", SkillVisibility.PUBLIC);
setId(hiddenSkill, 10L);
hiddenSkill.setStatus(SkillStatus.ACTIVE);
hiddenSkill.setLatestVersionId(101L);
hiddenSkill.setHidden(true);
Skill privateSkill = new Skill(1L, "private", "owner-1", SkillVisibility.PRIVATE);
setId(privateSkill, 11L);
privateSkill.setStatus(SkillStatus.ACTIVE);
privateSkill.setLatestVersionId(102L);
Skill archivedSkill = new Skill(2L, "archived", "owner-1", SkillVisibility.PUBLIC);
setId(archivedSkill, 12L);
archivedSkill.setStatus(SkillStatus.ACTIVE);
archivedSkill.setLatestVersionId(103L);
Skill unpublishedSkill = new Skill(1L, "unpublished", "owner-1", SkillVisibility.PUBLIC);
setId(unpublishedSkill, 13L);
unpublishedSkill.setStatus(SkillStatus.ACTIVE);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(activeNamespace));
when(namespaceRepository.findBySlug("archived")).thenReturn(Optional.of(archivedNamespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, "hidden")).thenReturn(List.of(hiddenSkill));
when(skillRepository.findByNamespaceIdAndSlug(1L, "private")).thenReturn(List.of(privateSkill));
when(skillRepository.findByNamespaceIdAndSlug(2L, "archived")).thenReturn(List.of(archivedSkill));
when(skillRepository.findByNamespaceIdAndSlug(1L, "unpublished")).thenReturn(List.of(unpublishedSkill));
assertThrows(DomainBadRequestException.class, () ->
service.resolveVersion("global", "hidden", null, null, null, null, Map.of()));
assertThrows(DomainForbiddenException.class, () ->
service.resolveVersion("global", "private", null, null, null, null, Map.of()));
assertThrows(DomainForbiddenException.class, () ->
service.resolveVersion("archived", "archived", null, null, null, null, Map.of()));
assertThrows(DomainBadRequestException.class, () ->
service.resolveVersion("global", "unpublished", null, null, null, null, Map.of()));
}
@Test
void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception {
String namespaceSlug = "test-ns";

View file

@ -363,6 +363,43 @@ class PostgresFullTextQueryServiceTest {
.contains("ORDER BY s.updated_at DESC, d.skill_id DESC");
}
@Test
void anonymousSearchSqlShouldOnlyReadPublicActiveVisibleNonArchivedSkills() {
EntityManager entityManager = mock(EntityManager.class);
Query nativeQuery = mock(Query.class);
Query countQuery = mock(Query.class);
when(entityManager.createNativeQuery(anyString()))
.thenReturn(nativeQuery)
.thenReturn(countQuery);
when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery);
when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery);
when(nativeQuery.getResultList()).thenReturn(List.of());
when(countQuery.getSingleResult()).thenReturn(0L);
PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager);
service.search(new SearchQuery(
null,
null,
new SearchVisibilityScope(null, Set.of(), Set.of()),
"newest",
0,
12
));
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
assertThat(sqlCaptor.getAllValues().getFirst())
.contains("AND (d.visibility = 'PUBLIC' )")
.contains("AND d.status = 'ACTIVE'")
.contains("AND s.status = 'ACTIVE'")
.contains("AND s.hidden = FALSE")
.contains("AND (n.status <> 'ARCHIVED' )")
.doesNotContain("memberNamespaceIds");
verify(nativeQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("memberNamespaceIds"), org.mockito.ArgumentMatchers.any());
verify(countQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("memberNamespaceIds"), org.mockito.ArgumentMatchers.any());
}
@Test
void authenticatedQueriesShouldAllowArchivedNamespacesForMembers() {
EntityManager entityManager = mock(EntityManager.class);