From eb7123c9f722343f26f7710c727512d3fd4aae9e Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 24 Apr 2026 13:46:34 +0800 Subject: [PATCH] feat(publish): support bulk skill package upload --- .../portal/SkillPublishController.java | 51 +---- .../support/ZipPackageExtractor.java | 1 + .../iflytek/skillhub/dto/PublishResponse.java | 18 +- .../dto/PublishResultDetailResponse.java | 12 + .../service/SkillPublishAppService.java | 150 +++++++++++++ .../support/SkillPackageArchiveExtractor.java | 4 +- .../portal/SkillPublishControllerTest.java | 72 +++--- .../service/SkillPublishAppServiceTest.java | 206 ++++++++++++++++++ .../SkillPackageArchiveExtractorTest.java | 2 +- web/e2e/helpers/test-data-builder.ts | 38 ++++ web/e2e/publish-flow-ui.spec.ts | 105 +++++++-- web/src/api/generated/schema.d.ts | 14 ++ web/src/api/types.ts | 6 +- web/src/features/publish/upload-zone.tsx | 8 +- web/src/i18n/locales/en.json | 7 +- web/src/i18n/locales/zh.json | 7 +- web/src/pages/dashboard/publish.tsx | 52 +++-- .../hooks/use-skill-queries.publish.test.ts | 95 ++++++++ web/src/shared/hooks/use-skill-queries.ts | 46 +++- web/src/shared/lib/publish-result.test.ts | 39 ++++ web/src/shared/lib/publish-result.ts | 17 ++ 21 files changed, 826 insertions(+), 124 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResultDetailResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java rename server/skillhub-app/src/main/java/com/iflytek/skillhub/{controller => service}/support/SkillPackageArchiveExtractor.java (97%) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPublishAppServiceTest.java rename server/skillhub-app/src/test/java/com/iflytek/skillhub/{controller => service}/support/SkillPackageArchiveExtractorTest.java (99%) create mode 100644 web/src/shared/hooks/use-skill-queries.publish.test.ts create mode 100644 web/src/shared/lib/publish-result.test.ts create mode 100644 web/src/shared/lib/publish-result.ts diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index 3abdf5e87..712237d79 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -2,45 +2,34 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; -import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; -import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.SkillVisibility; -import com.iflytek.skillhub.domain.skill.service.SkillPublishService; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; -import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; +import com.iflytek.skillhub.service.SkillPublishAppService; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.util.List; /** * Upload endpoints for skill packages. * - *

The controller is responsible for archive extraction and request shaping, - * while the domain service owns all publication validation and state changes. + *

The controller is responsible for request binding while the app service + * orchestrates archive extraction and publication. */ @RestController @RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillPublishController extends BaseApiController { - private final SkillPublishService skillPublishService; - private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; - private final SkillHubMetrics skillHubMetrics; + private final SkillPublishAppService skillPublishAppService; - public SkillPublishController(SkillPublishService skillPublishService, - SkillPackageArchiveExtractor skillPackageArchiveExtractor, - ApiResponseFactory responseFactory, - SkillHubMetrics skillHubMetrics) { + public SkillPublishController(SkillPublishAppService skillPublishAppService, + ApiResponseFactory responseFactory) { super(responseFactory); - this.skillPublishService = skillPublishService; - this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; - this.skillHubMetrics = skillHubMetrics; + this.skillPublishAppService = skillPublishAppService; } /** @@ -57,34 +46,14 @@ public ApiResponse publish( @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); - - List entries; - try { - entries = skillPackageArchiveExtractor.extract(file); - } catch (IllegalArgumentException e) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", e.getMessage()); - } - - SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( + PublishResponse response = skillPublishAppService.publish( namespace, - entries, - principal.userId(), + file, skillVisibility, + principal.userId(), principal.platformRoles(), confirmWarnings ); - - PublishResponse response = new PublishResponse( - publishResult.skillId(), - namespace, - publishResult.slug(), - publishResult.version().getVersion(), - publishResult.version().getStatus().name(), - publishResult.version().getFileCount(), - publishResult.version().getTotalSize() - ); - skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name()); - return ok("response.success.published", response); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index 2beaec705..629a8db86 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -3,6 +3,7 @@ import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.service.support.SkillPackageArchiveExtractor; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java index 6b62ccebe..025cc0121 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.dto; +import java.util.List; + public record PublishResponse( Long skillId, String namespace, @@ -7,5 +9,17 @@ public record PublishResponse( String version, String status, int fileCount, - long totalSize -) {} + long totalSize, + List results +) { + public PublishResponse( + Long skillId, + String namespace, + String slug, + String version, + String status, + int fileCount, + long totalSize) { + this(skillId, namespace, slug, version, status, fileCount, totalSize, List.of()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResultDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResultDetailResponse.java new file mode 100644 index 000000000..437d7d43e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResultDetailResponse.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.dto; + +public record PublishResultDetailResponse( + String packagePath, + Long skillId, + String namespace, + String slug, + String version, + String status, + int fileCount, + long totalSize +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java new file mode 100644 index 000000000..4f5c2f39d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java @@ -0,0 +1,150 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.dto.PublishResponse; +import com.iflytek.skillhub.dto.PublishResultDetailResponse; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.service.support.SkillPackageArchiveExtractor; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class SkillPublishAppService { + + private static final String SKILL_MD = "SKILL.md"; + private static final String SKILL_MD_SUFFIX = "/" + SKILL_MD; + + private final SkillPublishService skillPublishService; + private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; + private final SkillHubMetrics skillHubMetrics; + + public SkillPublishAppService(SkillPublishService skillPublishService, + SkillPackageArchiveExtractor skillPackageArchiveExtractor, + SkillHubMetrics skillHubMetrics) { + this.skillPublishService = skillPublishService; + this.skillPackageArchiveExtractor = skillPackageArchiveExtractor; + this.skillHubMetrics = skillHubMetrics; + } + + @Transactional + public PublishResponse publish(String namespace, + MultipartFile file, + SkillVisibility visibility, + String publisherId, + Set platformRoles, + boolean confirmWarnings) throws IOException { + List entries; + try { + entries = skillPackageArchiveExtractor.extract(file); + } catch (IllegalArgumentException e) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", e.getMessage()); + } + + List packages = discoverPackages(entries); + List details = new ArrayList<>(); + for (DiscoveredPackage skillPackage : packages) { + SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( + namespace, + skillPackage.entries(), + publisherId, + visibility, + platformRoles, + confirmWarnings + ); + details.add(toDetail(namespace, skillPackage.packagePath(), result)); + } + + for (PublishResultDetailResponse detail : details) { + skillHubMetrics.incrementSkillPublish(namespace, detail.status()); + } + + PublishResultDetailResponse first = details.getFirst(); + return new PublishResponse( + first.skillId(), + first.namespace(), + first.slug(), + first.version(), + first.status(), + first.fileCount(), + first.totalSize(), + List.copyOf(details) + ); + } + + private List discoverPackages(List entries) { + if (entries.stream().anyMatch(entry -> SKILL_MD.equals(entry.path()))) { + return List.of(new DiscoveredPackage("", entries)); + } + + List packageRoots = entries.stream() + .map(PackageEntry::path) + .filter(path -> path.endsWith(SKILL_MD_SUFFIX)) + .map(path -> path.substring(0, path.length() - SKILL_MD_SUFFIX.length())) + .distinct() + .sorted(Comparator.naturalOrder()) + .toList(); + + if (packageRoots.isEmpty()) { + throw new DomainBadRequestException("error.skill.publish.skillMd.notFound"); + } + + rejectNestedPackageRoots(packageRoots); + + List packages = new ArrayList<>(); + for (String root : packageRoots) { + String prefix = root + "/"; + List packageEntries = entries.stream() + .filter(entry -> entry.path().startsWith(prefix)) + .map(entry -> new PackageEntry( + entry.path().substring(prefix.length()), + entry.content(), + entry.size(), + entry.contentType() + )) + .toList(); + packages.add(new DiscoveredPackage(root, packageEntries)); + } + + return packages; + } + + private void rejectNestedPackageRoots(List packageRoots) { + for (int i = 0; i < packageRoots.size(); i++) { + String parent = packageRoots.get(i) + "/"; + for (int j = i + 1; j < packageRoots.size(); j++) { + if (packageRoots.get(j).startsWith(parent)) { + throw new DomainBadRequestException( + "error.skill.publish.package.invalid", + "Nested skill packages are not supported: " + packageRoots.get(j) + ); + } + } + } + } + + private PublishResultDetailResponse toDetail(String namespace, + String packagePath, + SkillPublishService.PublishResult result) { + return new PublishResultDetailResponse( + packagePath, + result.skillId(), + namespace, + result.slug(), + result.version().getVersion(), + result.version().getStatus().name(), + result.version().getFileCount(), + result.version().getTotalSize() + ); + } + + private record DiscoveredPackage(String packagePath, List entries) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/support/SkillPackageArchiveExtractor.java similarity index 97% rename from server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java rename to server/skillhub-app/src/main/java/com/iflytek/skillhub/service/support/SkillPackageArchiveExtractor.java index 5d98e93a2..ccd079561 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/support/SkillPackageArchiveExtractor.java @@ -1,4 +1,4 @@ -package com.iflytek.skillhub.controller.support; +package com.iflytek.skillhub.service.support; import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; @@ -80,7 +80,7 @@ public List extract(MultipartFile file) throws IOException { * If all file paths share a single root directory prefix (e.g., "my-skill/xxx"), * strip that prefix. Otherwise return entries unchanged. */ - static List stripSingleRootDirectory(List entries) { + public static List stripSingleRootDirectory(List entries) { if (entries.isEmpty()) return entries; Set rootSegments = new HashSet<>(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java index ae5fd27bb..d47c3e723 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java @@ -1,12 +1,9 @@ package com.iflytek.skillhub.controller.portal; -import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import org.mockito.ArgumentMatchers; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; @@ -17,11 +14,10 @@ import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; -import com.iflytek.skillhub.domain.skill.SkillVersion; -import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; -import com.iflytek.skillhub.domain.skill.service.SkillPublishService; -import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.dto.PublishResponse; +import com.iflytek.skillhub.dto.PublishResultDetailResponse; +import com.iflytek.skillhub.service.SkillPublishAppService; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.util.List; @@ -38,8 +34,8 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.multipart.MultipartFile; @SpringBootTest @AutoConfigureMockMvc @@ -51,7 +47,7 @@ class SkillPublishControllerTest { private MockMvc mockMvc; @MockBean - private SkillPublishService skillPublishService; + private SkillPublishAppService skillPublishAppService; @MockBean private NamespaceMemberRepository namespaceMemberRepository; @@ -59,25 +55,16 @@ class SkillPublishControllerTest { @MockBean private DeviceAuthService deviceAuthService; - @MockBean - private SkillHubMetrics skillHubMetrics; - @Test void publish_recordsMetricsAfterSuccess() throws Exception { - SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); - version.setStatus(SkillVersionStatus.PENDING_REVIEW); - version.setFileCount(1); - version.setTotalSize(128L); - ReflectionTestUtils.setField(version, "id", 34L); - - given(skillPublishService.publishFromEntries( + given(skillPublishAppService.publish( eq("global"), - ArgumentMatchers.>any(), - eq("usr_1"), + org.mockito.ArgumentMatchers.any(MultipartFile.class), eq(SkillVisibility.PUBLIC), + eq("usr_1"), eq(Set.of("SUPER_ADMIN")), eq(false))) - .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); + .willReturn(publishResponse("demo-skill", "PENDING_REVIEW")); PlatformPrincipal principal = new PlatformPrincipal( "usr_1", @@ -108,27 +95,20 @@ void publish_recordsMetricsAfterSuccess() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.skillId").value(12)) - .andExpect(jsonPath("$.data.slug").value("demo-skill")); - - verify(skillHubMetrics).incrementSkillPublish("global", "PENDING_REVIEW"); + .andExpect(jsonPath("$.data.slug").value("demo-skill")) + .andExpect(jsonPath("$.data.results[0].slug").value("demo-skill")); } @Test void publish_passesWarningConfirmationFlag() throws Exception { - SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); - version.setStatus(SkillVersionStatus.PENDING_REVIEW); - version.setFileCount(1); - version.setTotalSize(128L); - ReflectionTestUtils.setField(version, "id", 34L); - - given(skillPublishService.publishFromEntries( + given(skillPublishAppService.publish( eq("global"), - ArgumentMatchers.>any(), - eq("usr_1"), + org.mockito.ArgumentMatchers.any(MultipartFile.class), eq(SkillVisibility.PUBLIC), + eq("usr_1"), eq(Set.of("SUPER_ADMIN")), eq(true))) - .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); + .willReturn(publishResponse("demo-skill", "PENDING_REVIEW")); PlatformPrincipal principal = new PlatformPrincipal( "usr_1", @@ -159,6 +139,28 @@ void publish_passesWarningConfirmationFlag() throws Exception { .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)); + + verify(skillPublishAppService).publish( + eq("global"), + org.mockito.ArgumentMatchers.any(MultipartFile.class), + eq(SkillVisibility.PUBLIC), + eq("usr_1"), + eq(Set.of("SUPER_ADMIN")), + eq(true)); + } + + private PublishResponse publishResponse(String slug, String status) { + PublishResultDetailResponse detail = new PublishResultDetailResponse( + "", + 12L, + "global", + slug, + "1.0.0", + status, + 1, + 128L + ); + return new PublishResponse(12L, "global", slug, "1.0.0", status, 1, 128L, List.of(detail)); } private byte[] buildZipBytes() throws Exception { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPublishAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPublishAppServiceTest.java new file mode 100644 index 000000000..31669836c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPublishAppServiceTest.java @@ -0,0 +1,206 @@ +package com.iflytek.skillhub.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.dto.PublishResponse; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.service.support.SkillPackageArchiveExtractor; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.transaction.annotation.Transactional; + +@ExtendWith(MockitoExtension.class) +class SkillPublishAppServiceTest { + + @Mock + private SkillPublishService skillPublishService; + @Mock + private SkillPackageArchiveExtractor skillPackageArchiveExtractor; + @Mock + private SkillHubMetrics skillHubMetrics; + + @Test + void publish_discoversMultipleSkillDirectoriesAndPublishesInOneBatch() throws Exception { + SkillPublishAppService service = newService(); + MockMultipartFile file = upload(); + given(skillPackageArchiveExtractor.extract(file)).willReturn(List.of( + entry("alpha/SKILL.md", skillMd("Alpha Skill", "1.0.0")), + entry("alpha/README.md", "alpha readme"), + entry("beta/SKILL.md", skillMd("Beta Skill", "2.0.0")), + entry("beta/src/main.py", "print('beta')\n") + )); + given(skillPublishService.publishFromEntries( + eq("global"), + anyList(), + eq("usr_1"), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(true))) + .willReturn( + new SkillPublishService.PublishResult(101L, "alpha-skill", + version("1.0.0", SkillVersionStatus.PENDING_REVIEW, 2, 64L)), + new SkillPublishService.PublishResult(102L, "beta-skill", + version("2.0.0", SkillVersionStatus.PENDING_REVIEW, 2, 96L)) + ); + + PublishResponse response = service.publish( + "global", + file, + SkillVisibility.PUBLIC, + "usr_1", + Set.of("SUPER_ADMIN"), + true + ); + + assertEquals(101L, response.skillId()); + assertEquals("alpha-skill", response.slug()); + assertEquals(2, response.results().size()); + assertEquals("alpha", response.results().get(0).packagePath()); + assertEquals("beta", response.results().get(1).packagePath()); + assertEquals("beta-skill", response.results().get(1).slug()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> entriesCaptor = ArgumentCaptor.forClass(List.class); + verify(skillPublishService, org.mockito.Mockito.times(2)).publishFromEntries( + eq("global"), + entriesCaptor.capture(), + eq("usr_1"), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(true) + ); + assertEquals(List.of("SKILL.md", "README.md"), paths(entriesCaptor.getAllValues().get(0))); + assertEquals(List.of("SKILL.md", "src/main.py"), paths(entriesCaptor.getAllValues().get(1))); + verify(skillHubMetrics, org.mockito.Mockito.times(2)).incrementSkillPublish("global", "PENDING_REVIEW"); + } + + @Test + void publish_usesSingleRootPackageUnchangedForCompatibility() throws Exception { + SkillPublishAppService service = newService(); + MockMultipartFile file = upload(); + given(skillPackageArchiveExtractor.extract(file)).willReturn(List.of( + entry("SKILL.md", skillMd("Solo Skill", "1.0.0")), + entry("README.md", "solo readme") + )); + given(skillPublishService.publishFromEntries( + eq("global"), + anyList(), + eq("usr_1"), + eq(SkillVisibility.PRIVATE), + eq(Set.of()), + eq(false))) + .willReturn(new SkillPublishService.PublishResult(201L, "solo-skill", + version("1.0.0", SkillVersionStatus.UPLOADED, 2, 32L))); + + PublishResponse response = service.publish( + "global", + file, + SkillVisibility.PRIVATE, + "usr_1", + Set.of(), + false + ); + + assertEquals(201L, response.skillId()); + assertEquals("solo-skill", response.slug()); + assertEquals(1, response.results().size()); + assertEquals("", response.results().get(0).packagePath()); + } + + @Test + void publish_ignoresCollectionFilesOutsideDiscoveredSkillDirectories() throws Exception { + SkillPublishAppService service = newService(); + MockMultipartFile file = upload(); + given(skillPackageArchiveExtractor.extract(file)).willReturn(List.of( + entry("alpha/SKILL.md", skillMd("Alpha Skill", "1.0.0")), + entry("README.md", "collection readme") + )); + given(skillPublishService.publishFromEntries( + eq("global"), + anyList(), + eq("usr_1"), + eq(SkillVisibility.PUBLIC), + eq(Set.of()), + eq(false))) + .willReturn(new SkillPublishService.PublishResult(301L, "alpha-skill", + version("1.0.0", SkillVersionStatus.PENDING_REVIEW, 1, 24L))); + + PublishResponse response = service.publish( + "global", + file, + SkillVisibility.PUBLIC, + "usr_1", + Set.of(), + false + ); + + assertEquals(1, response.results().size()); + assertEquals("alpha-skill", response.results().get(0).slug()); + } + + @Test + void publishMethod_isTransactionalSoBatchPublishesShareOneTransaction() throws Exception { + Method method = SkillPublishAppService.class.getMethod( + "publish", + String.class, + org.springframework.web.multipart.MultipartFile.class, + SkillVisibility.class, + String.class, + Set.class, + boolean.class + ); + assertNotNull(method.getAnnotation(Transactional.class)); + } + + private SkillPublishAppService newService() { + return new SkillPublishAppService(skillPublishService, skillPackageArchiveExtractor, skillHubMetrics); + } + + private MockMultipartFile upload() { + return new MockMultipartFile("file", "skills.zip", "application/zip", new byte[] {1, 2, 3}); + } + + private PackageEntry entry(String path, String content) { + return new PackageEntry(path, content.getBytes(StandardCharsets.UTF_8), content.length(), "text/markdown"); + } + + private String skillMd(String name, String version) { + return """ + --- + name: %s + version: %s + --- + """.formatted(name, version); + } + + private SkillVersion version(String value, SkillVersionStatus status, int fileCount, long totalSize) { + SkillVersion version = new SkillVersion(1L, value, "usr_1"); + version.setStatus(status); + version.setFileCount(fileCount); + version.setTotalSize(totalSize); + return version; + } + + private List paths(List entries) { + return entries.stream().map(PackageEntry::path).toList(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/support/SkillPackageArchiveExtractorTest.java similarity index 99% rename from server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java rename to server/skillhub-app/src/test/java/com/iflytek/skillhub/service/support/SkillPackageArchiveExtractorTest.java index 403be311b..c7681db73 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/support/SkillPackageArchiveExtractorTest.java @@ -1,4 +1,4 @@ -package com.iflytek.skillhub.controller.support; +package com.iflytek.skillhub.service.support; import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 0123468ba..7dfd10729 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -156,6 +156,35 @@ function createSkillPackageZipFile(suffix: string, options?: SeedSkillOptions): } } +function createSkillCollectionPackageZipFile( + suffix: string, + packages: SeedSkillOptions[], +): { filePath: string; cleanup: () => void } { + const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-collection-')) + const collectionDir = path.join(tempRoot, `collection-${suffix}`) + const zipPath = path.join(tempRoot, `collection-${suffix}.zip`) + + execFileSync('mkdir', ['-p', collectionDir]) + writeFileSync(path.join(collectionDir, 'README.md'), '# Skill collection\n', 'utf8') + + packages.forEach((options, index) => { + const packageDir = path.join(collectionDir, `skill-${index + 1}`) + const { readmeHeading, skillMd } = buildSkillPackageContent(`${suffix}_${index}`, options) + execFileSync('mkdir', ['-p', packageDir]) + writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') + writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8') + }) + + execFileSync('zip', ['-q', '-r', zipPath, '.'], { cwd: collectionDir }) + + return { + filePath: zipPath, + cleanup: () => { + rmSync(tempRoot, { recursive: true, force: true }) + }, + } +} + async function parseEnvelope(response: Awaited>): Promise { const text = await response.text() let parsed: ApiEnvelope | null = null @@ -521,6 +550,15 @@ export class E2eTestDataBuilder { return filePath } + createSkillCollectionPackageFile(packages: SeedSkillOptions[]): string { + const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` + const { filePath, cleanup } = createSkillCollectionPackageZipFile(unique, packages) + this.cleanupTasks.push(async () => { + cleanup() + }) + return filePath + } + async createReviewData(): Promise { const namespace = await this.ensureReviewableNamespace() const skill = await this.publishSkill(namespace.slug) diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts index 867c8248b..e240afb89 100644 --- a/web/e2e/publish-flow-ui.spec.ts +++ b/web/e2e/publish-flow-ui.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' import path from 'node:path' import { setEnglishLocale } from './helpers/auth-fixtures' import { registerSession } from './helpers/session' @@ -8,12 +9,32 @@ interface PublishEnvelope { code: number msg?: string data: { - namespace: string - slug: string - version: string + namespace?: string + slug?: string + version?: string + results?: Array<{ + namespace: string + slug: string + version: string + }> } } +async function chooseNamespace(page: Page, namespaceSlug: string) { + const namespaceTrigger = page.locator('#namespace') + await expect(namespaceTrigger).toBeVisible() + await namespaceTrigger.click() + const namespaceOption = page.getByRole('option', { + name: new RegExp(`\\(@${namespaceSlug}\\)`), + }).first() + await expect(namespaceOption).toBeVisible() + await namespaceOption.evaluate((element: HTMLElement) => { + element.scrollIntoView({ block: 'center' }) + element.click() + }) + await expect(namespaceTrigger).toContainText(`@${namespaceSlug}`) +} + test.describe('Publish Flow UI (Real API)', () => { test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) @@ -31,19 +52,7 @@ test.describe('Publish Flow UI (Real API)', () => { await page.goto('/dashboard/publish') await expect(page.getByRole('heading', { name: 'Publish Skill' })).toBeVisible() - - const namespaceTrigger = page.locator('#namespace') - await expect(namespaceTrigger).toBeVisible() - await namespaceTrigger.click() - const namespaceOption = page.getByRole('option', { - name: new RegExp(`\\(@${namespace.slug}\\)`), - }).first() - await expect(namespaceOption).toBeVisible() - await namespaceOption.evaluate((element: HTMLElement) => { - element.scrollIntoView({ block: 'center' }) - element.click() - }) - await expect(namespaceTrigger).toContainText(`@${namespace.slug}`) + await chooseNamespace(page, namespace.slug) await page.locator('input[type="file"]').setInputFiles(packagePath) await expect(page.getByText(path.basename(packagePath))).toBeVisible() @@ -72,4 +81,68 @@ test.describe('Publish Flow UI (Real API)', () => { await builder.cleanup() } }) + + test('publishes a folder-packed skill collection and shows batch success toast', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + const suffix = Date.now().toString(36) + const firstSkillName = `publish-bulk-a-${suffix}` + const secondSkillName = `publish-bulk-b-${suffix}` + const packagePath = builder.createSkillCollectionPackageFile([ + { name: firstSkillName }, + { name: secondSkillName }, + ]) + + await page.goto('/dashboard/publish') + await chooseNamespace(page, namespace.slug) + + await page.locator('input[type="file"]').setInputFiles(packagePath) + await expect(page.getByText(path.basename(packagePath))).toBeVisible() + + const confirmButton = page.getByRole('button', { name: 'Confirm Publish' }) + await expect(confirmButton).toBeEnabled() + await confirmButton.click() + + await expect(page.getByText('Batch publish completed')).toBeVisible({ timeout: 90_000 }) + await expect(page.getByText('2 skill packages were published successfully.')).toBeVisible() + await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('heading', { name: firstSkillName, exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('heading', { name: secondSkillName, exact: true })).toBeVisible({ timeout: 30_000 }) + } finally { + await builder.cleanup() + } + }) + + test('keeps the publish page open when uploading an invalid package', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + const invalidPackagePath = path.join(process.cwd(), 'e2e', 'fixtures', 'sample-skill.zip') + + await page.goto('/dashboard/publish') + await chooseNamespace(page, namespace.slug) + await page.locator('input[type="file"]').setInputFiles(invalidPackagePath) + await expect(page.getByText(path.basename(invalidPackagePath))).toBeVisible() + + const publishResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.url().includes(`/api/web/skills/${encodeURIComponent(namespace.slug)}/publish`), + { timeout: 90_000 }, + ) + await page.getByRole('button', { name: 'Confirm Publish' }).click() + const publishResponse = await publishResponsePromise + expect(publishResponse.ok()).toBe(false) + + await expect(page.getByText('Publish Failed')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Publish Skill' })).toBeVisible() + } finally { + await builder.cleanup() + } + }) }) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a734922a4..c8c34c55e 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3351,6 +3351,20 @@ export interface components { fileCount?: number; /** Format: int64 */ totalSize?: number; + results?: components["schemas"]["PublishResultDetailResponse"][]; + }; + PublishResultDetailResponse: { + packagePath?: string; + /** Format: int64 */ + skillId?: number; + namespace?: string; + slug?: string; + version?: string; + status?: string; + /** Format: int32 */ + fileCount?: number; + /** Format: int64 */ + totalSize?: number; }; ReviewActionRequest: { comment?: string; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 2b2f93823..14aaf7985 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -285,7 +285,7 @@ export interface PagedResponse { } // Publish -export interface PublishResult { +export interface PublishResultItem { skillId: number namespace: string slug: string @@ -295,6 +295,10 @@ export interface PublishResult { totalSize: number } +export type PublishResult = PublishResultItem & { + results?: PublishResultItem[] +} + export interface SkillDeleteResult { skillId?: number namespace?: string diff --git a/web/src/features/publish/upload-zone.tsx b/web/src/features/publish/upload-zone.tsx index b28fa92be..b72a0ec5b 100644 --- a/web/src/features/publish/upload-zone.tsx +++ b/web/src/features/publish/upload-zone.tsx @@ -4,12 +4,12 @@ import { useDropzone } from 'react-dropzone' import { cn } from '@/shared/lib/utils' interface UploadZoneProps { - onFileSelect: (file: File) => void + onFileSelect: (files: File[]) => void disabled?: boolean } /** - * Provides the publish page dropzone for uploading one zip package at a time. + * Provides the publish page dropzone for uploading zip packages. * The component is intentionally stateless so packaging validation can remain in * the publish flow that knows the surrounding form and backend constraints. */ @@ -18,7 +18,7 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) { const onDrop = useCallback( (acceptedFiles: File[]) => { if (acceptedFiles.length > 0) { - onFileSelect(acceptedFiles[0]) + onFileSelect(acceptedFiles) } }, [onFileSelect] @@ -29,7 +29,7 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) { accept: { 'application/zip': ['.zip'], }, - maxFiles: 1, + multiple: true, disabled, }) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index c96495f9d..0aadf2720 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1063,7 +1063,7 @@ }, "upload": { "dropHint": "Drop to upload...", - "dragHint": "Drag a ZIP file here, or click to select", + "dragHint": "Drag ZIP files here, or click to select", "formatHint": "Only .zip format supported" }, "layout": { @@ -1212,13 +1212,18 @@ "private": "Private" }, "file": "Skill Package File", + "selectedFileCount": "1 file selected", + "selectedFilesCount": "{{count}} files selected", "removeSelectedFile": "Remove selected file", + "removeSelectedFiles": "Remove selected files", "publishing": "Publishing...", "confirm": "Confirm Publish", "success": "Published Successfully", "successDescription": "{{skill}} has been submitted for review and will be available after admin approval", "publishedTitle": "Published Successfully", "publishedDescription": "{{skill}} is now live and available for download", + "bulkSuccessTitle": "Batch publish completed", + "bulkSuccessDescription": "{{count}} skill packages were published successfully.", "pendingReviewTitle": "Submitted for Review", "pendingReviewDescription": "{{skill}} has been submitted for review and will be available after admin approval", "error": "Publish Failed", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 122192852..d57f9841b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1064,7 +1064,7 @@ }, "upload": { "dropHint": "放开以上传文件...", - "dragHint": "拖拽 ZIP 文件到此处,或点击选择", + "dragHint": "拖拽 ZIP 文件到此处,或点击选择多个文件", "formatHint": "仅支持 .zip 格式" }, "layout": { @@ -1213,13 +1213,18 @@ "private": "私有" }, "file": "技能包文件", + "selectedFileCount": "已选择 1 个文件", + "selectedFilesCount": "已选择 {{count}} 个文件", "removeSelectedFile": "删除已选文件", + "removeSelectedFiles": "删除已选文件", "publishing": "发布中...", "confirm": "确认发布", "success": "发布成功", "successDescription": "{{skill}} 已提交审核,等待管理员批准后即可使用", "publishedTitle": "发布成功", "publishedDescription": "{{skill}} 已发布,现在可以下载使用", + "bulkSuccessTitle": "批量发布完成", + "bulkSuccessDescription": "{{count}} 个技能包已发布成功。", "pendingReviewTitle": "已提交审核", "pendingReviewDescription": "{{skill}} 已提交审核,等待管理员批准后即可使用", "error": "发布失败", diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 3b2ec57e3..dcdf60eb9 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -25,6 +25,7 @@ import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { toast } from '@/shared/lib/toast' +import { formatPublishResultLabel, getPublishResultItems } from '@/shared/lib/publish-result' import { ApiError } from '@/api/client' const EMPTY_NAMESPACE_VALUE = '__select_namespace__' @@ -32,7 +33,7 @@ const EMPTY_NAMESPACE_VALUE = '__select_namespace__' export function PublishPage() { const { t } = useTranslation() const navigate = useNavigate() - const [selectedFile, setSelectedFile] = useState(null) + const [selectedFiles, setSelectedFiles] = useState([]) const [namespaceSlug, setNamespaceSlug] = useState('') const [visibility, setVisibility] = useState('PUBLIC') const [warningDialogOpen, setWarningDialogOpen] = useState(false) @@ -46,19 +47,19 @@ export function PublishPage() { : t('publish.visibilityOptions.namespaceOnly') const handleRemoveSelectedFile = () => { - setSelectedFile(null) + setSelectedFiles([]) setPrecheckWarnings([]) setWarningDialogOpen(false) } - const handleFileSelect = (file: File | null) => { - setSelectedFile(file) + const handleFileSelect = (files: File[]) => { + setSelectedFiles(files) setPrecheckWarnings([]) setWarningDialogOpen(false) } const publishSkill = async (confirmWarnings = false) => { - if (!selectedFile || !namespaceSlug) { + if (selectedFiles.length === 0 || !namespaceSlug) { toast.error(t('publish.selectRequired')) return } @@ -66,22 +67,28 @@ export function PublishPage() { try { const result = await publishMutation.mutateAsync({ namespace: namespaceSlug, - file: selectedFile, + files: selectedFiles, visibility, confirmWarnings, }) setPrecheckWarnings([]) setWarningDialogOpen(false) - const skillLabel = `${result.namespace}/${result.slug}@${result.version}` - if (result.status === 'PUBLISHED') { + const publishResults = getPublishResultItems(result) + + if (publishResults.length > 1) { + toast.success( + t('publish.bulkSuccessTitle'), + t('publish.bulkSuccessDescription', { count: publishResults.length }) + ) + } else if (publishResults[0]?.status === 'PUBLISHED') { toast.success( t('publish.publishedTitle'), - t('publish.publishedDescription', { skill: skillLabel }) + t('publish.publishedDescription', { skill: formatPublishResultLabel(publishResults[0]) }) ) } else { toast.success( t('publish.pendingReviewTitle'), - t('publish.pendingReviewDescription', { skill: skillLabel }) + t('publish.pendingReviewDescription', { skill: publishResults[0] ? formatPublishResultLabel(publishResults[0]) : '' }) ) } navigate({ to: '/dashboard/skills' }) @@ -189,19 +196,28 @@ export function PublishPage() {

`${file.name}-${file.lastModified}`).join('|') || 'empty'} onFileSelect={handleFileSelect} disabled={publishMutation.isPending} /> - {selectedFile && ( + {selectedFiles.length > 0 && (
-
+
- - {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB) - +
+

+ {selectedFiles.length === 1 + ? t('publish.selectedFileCount') + : t('publish.selectedFilesCount', { count: selectedFiles.length })} +

+ {selectedFiles.map((file) => ( +

+ {file.name} ({(file.size / 1024).toFixed(1)} KB) +

+ ))} +
)} @@ -220,7 +236,7 @@ export function PublishPage() { className="w-full text-primary-foreground disabled:text-primary-foreground" size="lg" onClick={handlePublish} - disabled={!selectedFile || !namespaceSlug || publishMutation.isPending} + disabled={selectedFiles.length === 0 || !namespaceSlug || publishMutation.isPending} > {publishMutation.isPending ? t('publish.publishing') : t('publish.confirm')} diff --git a/web/src/shared/hooks/use-skill-queries.publish.test.ts b/web/src/shared/hooks/use-skill-queries.publish.test.ts new file mode 100644 index 000000000..eb9823757 --- /dev/null +++ b/web/src/shared/hooks/use-skill-queries.publish.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PublishResult } from '@/api/types' + +const mocks = vi.hoisted(() => ({ + fetchJson: vi.fn<() => Promise>(), +})) + +interface MutationOptions { + mutationFn: (variables: TVariables) => Promise +} + +interface PublishVariables { + namespace: string + files: File[] + visibility: string +} + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (options: MutationOptions) => options, + useQuery: vi.fn(), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})) + +vi.mock('@/api/client', () => ({ + WEB_API_PREFIX: '/api/web', + fetchJson: mocks.fetchJson, + fetchText: vi.fn(), + getCsrfHeaders: () => ({ 'X-XSRF-TOKEN': 'token' }), + skillLifecycleApi: {}, +})) + +vi.mock('@/features/skill/skill-delete-flow', () => ({ + clearDeletedSkillQueries: vi.fn(), +})) + +import { usePublishSkill } from './use-skill-queries' + +const alphaResult = { + skillId: 1, + namespace: 'team', + slug: 'alpha', + version: '1.0.0', + status: 'PUBLISHED', + fileCount: 2, + totalSize: 128, +} + +const betaResult = { + ...alphaResult, + skillId: 2, + slug: 'beta', +} + +function createZip(name: string): File { + return new File(['zip'], name, { type: 'application/zip' }) +} + +describe('usePublishSkill mutation', () => { + beforeEach(() => { + mocks.fetchJson.mockReset() + }) + + it('returns backend batch results from a single publish request', async () => { + mocks.fetchJson.mockResolvedValueOnce({ + ...alphaResult, + results: [alphaResult, betaResult], + }) + + const mutation = usePublishSkill() as unknown as MutationOptions + const result = await mutation.mutationFn({ + namespace: 'team', + files: [createZip('alpha.zip'), createZip('beta.zip')], + visibility: 'PUBLIC', + }) + + expect(mocks.fetchJson).toHaveBeenCalledTimes(1) + expect(result.results?.map((item) => item.slug)).toEqual(['alpha', 'beta']) + }) + + it('falls back to single-file requests when the backend only returns one result', async () => { + mocks.fetchJson + .mockResolvedValueOnce(alphaResult) + .mockResolvedValueOnce(betaResult) + + const mutation = usePublishSkill() as unknown as MutationOptions + const result = await mutation.mutationFn({ + namespace: 'team', + files: [createZip('alpha.zip'), createZip('beta.zip')], + visibility: 'PUBLIC', + }) + + expect(mocks.fetchJson).toHaveBeenCalledTimes(2) + expect(result.results?.map((item) => item.slug)).toEqual(['alpha', 'beta']) + }) +}) diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index cf19d18cc..ad02778ba 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult } from '@/api/types' import { fetchJson, fetchText, getCsrfHeaders, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client' import { clearDeletedSkillQueries } from '@/features/skill/skill-delete-flow' +import { getPublishResultItems } from '@/shared/lib/publish-result' import { getSkillDetailQueryKey } from './query-keys' import { buildSkillSearchUrl } from './skill-query-helpers' @@ -37,10 +38,28 @@ async function getSkillDocumentation(namespace: string, slug: string, version: s return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/file?path=${encodeURIComponent(path)}`) } -async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean }): Promise { +interface PublishSkillParams { + namespace: string + file?: File + files?: File[] + visibility: string + confirmWarnings?: boolean +} + +function getPublishFiles(params: PublishSkillParams): File[] { + if (params.files && params.files.length > 0) { + return params.files + } + + return params.file ? [params.file] : [] +} + +async function publishFiles(params: PublishSkillParams, files: File[]): Promise { const cleanNamespace = params.namespace.startsWith('@') ? params.namespace.slice(1) : params.namespace const formData = new FormData() - formData.append('file', params.file) + for (const file of files) { + formData.append('file', file) + } formData.append('visibility', params.visibility) formData.append('confirmWarnings', String(params.confirmWarnings === true)) @@ -52,6 +71,29 @@ async function publishSkill(params: { namespace: string; file: File; visibility: }) } +async function publishSkill(params: PublishSkillParams): Promise { + const files = getPublishFiles(params) + const result = await publishFiles(params, files) + const resultItems = getPublishResultItems(result) + + if (files.length <= 1 || resultItems.length >= files.length) { + return result + } + + const remainingResults: PublishResult[] = [] + for (const file of files.slice(resultItems.length)) { + remainingResults.push(await publishFiles(params, [file])) + } + + return { + ...result, + results: [ + ...resultItems, + ...remainingResults.flatMap(getPublishResultItems), + ], + } +} + export function useSearchSkills(params: SearchParams) { return useQuery({ queryKey: ['skills', 'search', params], diff --git a/web/src/shared/lib/publish-result.test.ts b/web/src/shared/lib/publish-result.test.ts new file mode 100644 index 000000000..d9da6cffa --- /dev/null +++ b/web/src/shared/lib/publish-result.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import type { PublishResult } from '@/api/types' +import { formatPublishResultLabel, getPublishResultItems, isPublishResultItem } from './publish-result' + +const singleResult = { + skillId: 1, + namespace: 'team', + slug: 'alpha', + version: '1.0.0', + status: 'PUBLISHED', + fileCount: 2, + totalSize: 128, +} + +describe('publish-result helpers', () => { + it('keeps the legacy single publish response as one result item', () => { + expect(isPublishResultItem(singleResult)).toBe(true) + expect(getPublishResultItems(singleResult)).toEqual([singleResult]) + expect(formatPublishResultLabel(singleResult)).toBe('team/alpha@1.0.0') + }) + + it('prefers backend batch results when present', () => { + const batchResult: PublishResult = { + ...singleResult, + results: [ + singleResult, + { ...singleResult, skillId: 2, slug: 'beta', status: 'PENDING_REVIEW' }, + ], + } + + expect(getPublishResultItems(batchResult).map((item) => item.slug)).toEqual(['alpha', 'beta']) + }) + + it('falls back to the top-level publish result when batch results are empty', () => { + const result: PublishResult = { ...singleResult, results: [] } + + expect(getPublishResultItems(result)).toEqual([result]) + }) +}) diff --git a/web/src/shared/lib/publish-result.ts b/web/src/shared/lib/publish-result.ts new file mode 100644 index 000000000..0bbf8d189 --- /dev/null +++ b/web/src/shared/lib/publish-result.ts @@ -0,0 +1,17 @@ +import type { PublishResult, PublishResultItem } from '@/api/types' + +export function isPublishResultItem(value: PublishResult): value is PublishResultItem { + return typeof value.skillId === 'number' +} + +export function getPublishResultItems(result: PublishResult): PublishResultItem[] { + if (Array.isArray(result.results) && result.results.length > 0) { + return result.results + } + + return [result] +} + +export function formatPublishResultLabel(result: PublishResultItem): string { + return `${result.namespace}/${result.slug}@${result.version}` +}