From f96fa7441394c19e7cd0575fd5e71bfbcea5ff67 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Fri, 13 Mar 2026 16:04:18 +0800 Subject: [PATCH] feat(compat): add clawhub registry adapter --- .../compat/ClawHubRegistryController.java | 54 +++++ .../compat/ClawHubRegistryFacade.java | 213 +++++++++++++++++ .../compat/ClawHubRegistrySecurityConfig.java | 24 ++ .../compat/dto/ClawHubRegistryModeration.java | 25 ++ .../compat/dto/ClawHubRegistryOwner.java | 14 ++ .../compat/dto/ClawHubRegistrySearchItem.java | 10 + .../dto/ClawHubRegistrySearchResponse.java | 5 + .../compat/dto/ClawHubRegistrySkill.java | 14 ++ .../dto/ClawHubRegistrySkillResponse.java | 11 + .../dto/ClawHubRegistrySkillVersion.java | 8 + .../compat/ClawHubRegistryControllerTest.java | 220 ++++++++++++++++++ 11 files changed, 598 insertions(+) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryModeration.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryOwner.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchItem.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkill.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillVersion.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryController.java new file mode 100644 index 00000000..c3a55512 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryController.java @@ -0,0 +1,54 @@ +package com.iflytek.skillhub.compat; + +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySearchResponse; +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkillResponse; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import java.util.Map; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1") +public class ClawHubRegistryController { + + private final ClawHubRegistryFacade facade; + + public ClawHubRegistryController(ClawHubRegistryFacade facade) { + this.facade = facade; + } + + @GetMapping("/search") + public ClawHubRegistrySearchResponse search( + @RequestParam String q, + @RequestParam(defaultValue = "20") int limit, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return facade.search(q, limit, userId, userNsRoles); + } + + @GetMapping("/skills/{slug}") + public ClawHubRegistrySkillResponse getSkill( + @org.springframework.web.bind.annotation.PathVariable String slug, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return facade.getSkill(slug, userId, userNsRoles); + } + + @GetMapping("/download") + public ResponseEntity download( + @RequestParam String slug, + @RequestParam(required = false) String version, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + String location = facade.resolveDownloadUrl(slug, version, userId, userNsRoles); + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, location) + .build(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java new file mode 100644 index 00000000..934b3ecc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java @@ -0,0 +1,213 @@ +package com.iflytek.skillhub.compat; + +import com.iflytek.skillhub.compat.dto.ClawHubRegistryModeration; +import com.iflytek.skillhub.compat.dto.ClawHubRegistryOwner; +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySearchItem; +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySearchResponse; +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkill; +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkillResponse; +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkillVersion; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.service.SkillSearchAppService; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.springframework.stereotype.Component; + +@Component +public class ClawHubRegistryFacade { + + private static final int DEFAULT_LIMIT = 20; + private static final int MAX_LIMIT = 100; + + private final CanonicalSlugMapper canonicalSlugMapper; + private final SkillSearchAppService skillSearchAppService; + private final SkillQueryService skillQueryService; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final UserAccountRepository userAccountRepository; + + public ClawHubRegistryFacade( + CanonicalSlugMapper canonicalSlugMapper, + SkillSearchAppService skillSearchAppService, + SkillQueryService skillQueryService, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + UserAccountRepository userAccountRepository) { + this.canonicalSlugMapper = canonicalSlugMapper; + this.skillSearchAppService = skillSearchAppService; + this.skillQueryService = skillQueryService; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.userAccountRepository = userAccountRepository; + } + + public ClawHubRegistrySearchResponse search( + String keyword, + int limit, + String userId, + Map userNsRoles) { + int boundedLimit = clampLimit(limit); + List items = skillSearchAppService.search( + keyword, + null, + "relevance", + 0, + boundedLimit, + userId, + normalizeRoles(userNsRoles)) + .items(); + + List results = buildSearchResults(items); + return new ClawHubRegistrySearchResponse(results); + } + + public ClawHubRegistrySkillResponse getSkill( + String canonicalSlug, + String userId, + Map userNsRoles) { + SkillCoordinate coordinate = canonicalSlugMapper.fromCanonical(canonicalSlug); + SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail( + coordinate.namespace(), + coordinate.slug(), + userId, + normalizeRoles(userNsRoles)); + + Skill skill = skillRepository.findById(detail.id()) + .orElseThrow(() -> new IllegalStateException("Skill unexpectedly missing: " + canonicalSlug)); + + ClawHubRegistrySkill payload = new ClawHubRegistrySkill( + canonicalSlugMapper.toCanonical(coordinate.namespace(), detail.slug()), + normalizeDisplayName(detail.displayName(), canonicalSlug), + detail.summary(), + List.of(), + Map.of(), + toEpochMillis(skill.getCreatedAt()), + toEpochMillis(skill.getUpdatedAt()) + ); + + ClawHubRegistrySkillVersion latestVersion = buildLatestVersion(skill, detail.latestVersion()); + ClawHubRegistryOwner owner = buildOwner(skill.getOwnerId()); + + return new ClawHubRegistrySkillResponse( + payload, + latestVersion, + owner, + ClawHubRegistryModeration.clean() + ); + } + + public String resolveDownloadUrl( + String canonicalSlug, + String version, + String userId, + Map userNsRoles) { + SkillCoordinate coordinate = canonicalSlugMapper.fromCanonical(canonicalSlug); + String normalizedVersion = normalizeVersion(version); + return skillQueryService.resolveVersion( + coordinate.namespace(), + coordinate.slug(), + normalizedVersion, + null, + null, + userId, + normalizeRoles(userNsRoles)) + .downloadUrl(); + } + + private List buildSearchResults(List items) { + return java.util.stream.IntStream.range(0, items.size()) + .mapToObj(index -> toSearchItem(items.get(index), index)) + .toList(); + } + + private ClawHubRegistrySearchItem toSearchItem(SkillSummaryResponse item, int index) { + String canonicalSlug = canonicalSlugMapper.toCanonical(item.namespace(), item.slug()); + return new ClawHubRegistrySearchItem( + canonicalSlug, + normalizeDisplayName(item.displayName(), canonicalSlug), + item.summary(), + item.latestVersion(), + scoreFor(index), + toEpochMillis(item.updatedAt()) + ); + } + + private ClawHubRegistrySkillVersion buildLatestVersion(Skill skill, String version) { + if (version == null || version.isBlank()) { + return null; + } + + Optional latestVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version); + if (latestVersion.isEmpty()) { + return new ClawHubRegistrySkillVersion(version, 0L, "", null); + } + + SkillVersion entity = latestVersion.get(); + LocalDateTime createdAt = entity.getPublishedAt() != null ? entity.getPublishedAt() : entity.getCreatedAt(); + return new ClawHubRegistrySkillVersion( + entity.getVersion(), + toEpochMillis(createdAt), + entity.getChangelog() != null ? entity.getChangelog() : "", + null + ); + } + + private ClawHubRegistryOwner buildOwner(String ownerId) { + if (ownerId == null || ownerId.isBlank()) { + return ClawHubRegistryOwner.empty(); + } + + return userAccountRepository.findById(ownerId) + .map(user -> new ClawHubRegistryOwner( + null, + user.getDisplayName(), + user.getAvatarUrl())) + .orElseGet(ClawHubRegistryOwner::empty); + } + + private Map normalizeRoles(Map userNsRoles) { + return userNsRoles != null ? userNsRoles : Map.of(); + } + + private int clampLimit(int limit) { + if (limit <= 0) { + return DEFAULT_LIMIT; + } + return Math.min(limit, MAX_LIMIT); + } + + private String normalizeDisplayName(String displayName, String fallback) { + if (displayName == null || displayName.isBlank()) { + return fallback; + } + return displayName; + } + + private String normalizeVersion(String version) { + if (version == null || version.isBlank()) { + return null; + } + return version; + } + + private double scoreFor(int index) { + return Math.max(0.001d, 1.0d - (index * 0.001d)); + } + + private long toEpochMillis(LocalDateTime timestamp) { + if (timestamp == null) { + return 0L; + } + return timestamp.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java new file mode 100644 index 00000000..66a632e9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.compat; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class ClawHubRegistrySecurityConfig { + + @Bean + @Order(0) + public SecurityFilterChain clawHubRegistryFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/api/v1/search", "/api/v1/download", "/api/v1/skills/*") + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .requestCache(cache -> cache.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return http.build(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryModeration.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryModeration.java new file mode 100644 index 00000000..2f450ff1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryModeration.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.compat.dto; + +import java.util.List; + +public record ClawHubRegistryModeration( + boolean isSuspicious, + boolean isMalwareBlocked, + String verdict, + List reasonCodes, + Long updatedAt, + String engineVersion, + String summary +) { + public static ClawHubRegistryModeration clean() { + return new ClawHubRegistryModeration( + false, + false, + "clean", + List.of(), + null, + null, + null + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryOwner.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryOwner.java new file mode 100644 index 00000000..3819552a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistryOwner.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.compat.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.ALWAYS) +public record ClawHubRegistryOwner( + String handle, + String displayName, + String image +) { + public static ClawHubRegistryOwner empty() { + return new ClawHubRegistryOwner(null, null, null); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchItem.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchItem.java new file mode 100644 index 00000000..960a6f5b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchItem.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubRegistrySearchItem( + String slug, + String displayName, + String summary, + String version, + double score, + long updatedAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchResponse.java new file mode 100644 index 00000000..9e900562 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySearchResponse.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.compat.dto; + +import java.util.List; + +public record ClawHubRegistrySearchResponse(List results) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkill.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkill.java new file mode 100644 index 00000000..15819875 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkill.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.compat.dto; + +import java.util.List; +import java.util.Map; + +public record ClawHubRegistrySkill( + String slug, + String displayName, + String summary, + List tags, + Map stats, + long createdAt, + long updatedAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillResponse.java new file mode 100644 index 00000000..771fcea2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.compat.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.ALWAYS) +public record ClawHubRegistrySkillResponse( + ClawHubRegistrySkill skill, + ClawHubRegistrySkillVersion latestVersion, + ClawHubRegistryOwner owner, + ClawHubRegistryModeration moderation +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillVersion.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillVersion.java new file mode 100644 index 00000000..686f72c6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubRegistrySkillVersion.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubRegistrySkillVersion( + String version, + long createdAt, + String changelog, + Object license +) {} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java new file mode 100644 index 00000000..cadd26cd --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java @@ -0,0 +1,220 @@ +package com.iflytek.skillhub.compat; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.service.SkillSearchAppService; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ClawHubRegistryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private SkillSearchAppService skillSearchAppService; + + @MockBean + private SkillQueryService skillQueryService; + + @MockBean + private SkillRepository skillRepository; + + @MockBean + private SkillVersionRepository skillVersionRepository; + + @MockBean + private UserAccountRepository userAccountRepository; + + @Test + void search_returns_clawhub_registry_schema() throws Exception { + LocalDateTime updatedAt = LocalDateTime.of(2026, 3, 13, 10, 30); + given(skillSearchAppService.search("test", null, "relevance", 0, 2, null, Map.of())) + .willReturn(new SkillSearchAppService.SearchResponse( + List.of( + new SkillSummaryResponse( + 1L, + "global-skill", + "Global Skill", + "global summary", + 10L, + 5, + BigDecimal.ZERO, + 0, + "1.2.0", + "global", + updatedAt + ), + new SkillSummaryResponse( + 2L, + "team-skill", + "Team Skill", + "team summary", + 20L, + 8, + BigDecimal.ONE, + 2, + "2.0.0", + "team-ai", + updatedAt.plusHours(1) + ) + ), + 2, + 0, + 2 + )); + + mockMvc.perform(get("/api/v1/search") + .param("q", "test") + .param("limit", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results").isArray()) + .andExpect(jsonPath("$.results[0].slug").value("global-skill")) + .andExpect(jsonPath("$.results[0].displayName").value("Global Skill")) + .andExpect(jsonPath("$.results[0].version").value("1.2.0")) + .andExpect(jsonPath("$.results[0].score").value(1.0)) + .andExpect(jsonPath("$.results[0].updatedAt").value(toEpochMillis(updatedAt))) + .andExpect(jsonPath("$.results[1].slug").value("team-ai--team-skill")) + .andExpect(jsonPath("$.results[1].displayName").value("Team Skill")) + .andExpect(jsonPath("$.results[1].version").value("2.0.0")) + .andExpect(jsonPath("$.results[1].score").value(0.999)) + .andExpect(jsonPath("$.results[1].updatedAt").value(toEpochMillis(updatedAt.plusHours(1)))); + } + + @Test + void get_skill_returns_clawhub_install_metadata() throws Exception { + LocalDateTime skillCreatedAt = LocalDateTime.of(2026, 1, 1, 9, 0); + LocalDateTime skillUpdatedAt = LocalDateTime.of(2026, 3, 10, 18, 30); + LocalDateTime versionPublishedAt = LocalDateTime.of(2026, 3, 12, 12, 0); + + given(skillQueryService.getSkillDetail( + eq("global"), + eq("global-skill"), + isNull(), + eq(Map.of()))) + .willReturn(new SkillQueryService.SkillDetailDTO( + 1L, + "global-skill", + "Global Skill", + "global summary", + "PUBLIC", + "ACTIVE", + 10L, + 5, + BigDecimal.ZERO, + 0, + false, + "1.2.0", + 1L + )); + + Skill skill = new Skill(1L, "global-skill", "owner-1", SkillVisibility.PUBLIC); + skill.setDisplayName("Global Skill"); + skill.setSummary("global summary"); + ReflectionTestUtils.setField(skill, "id", 1L); + ReflectionTestUtils.setField(skill, "createdAt", skillCreatedAt); + ReflectionTestUtils.setField(skill, "updatedAt", skillUpdatedAt); + + SkillVersion version = new SkillVersion(1L, "1.2.0", "owner-1"); + version.setChangelog("Initial release"); + version.setPublishedAt(versionPublishedAt); + ReflectionTestUtils.setField(version, "id", 11L); + ReflectionTestUtils.setField(version, "createdAt", versionPublishedAt.minusHours(2)); + + given(skillRepository.findById(1L)).willReturn(java.util.Optional.of(skill)); + given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.0")).willReturn(java.util.Optional.of(version)); + given(userAccountRepository.findById("owner-1")) + .willReturn(java.util.Optional.of(new UserAccount( + "owner-1", + "Skill Owner", + "owner@example.com", + "https://example.com/avatar.png" + ))); + + mockMvc.perform(get("/api/v1/skills/global-skill")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.skill.slug").value("global-skill")) + .andExpect(jsonPath("$.skill.displayName").value("Global Skill")) + .andExpect(jsonPath("$.skill.summary").value("global summary")) + .andExpect(jsonPath("$.skill.createdAt").value(toEpochMillis(skillCreatedAt))) + .andExpect(jsonPath("$.skill.updatedAt").value(toEpochMillis(skillUpdatedAt))) + .andExpect(jsonPath("$.latestVersion.version").value("1.2.0")) + .andExpect(jsonPath("$.latestVersion.createdAt").value(toEpochMillis(versionPublishedAt))) + .andExpect(jsonPath("$.latestVersion.changelog").value("Initial release")) + .andExpect(jsonPath("$.owner.displayName").value("Skill Owner")) + .andExpect(jsonPath("$.owner.image").value("https://example.com/avatar.png")) + .andExpect(jsonPath("$.moderation.isSuspicious").value(false)) + .andExpect(jsonPath("$.moderation.isMalwareBlocked").value(false)) + .andExpect(jsonPath("$.moderation.verdict").value("clean")); + } + + @Test + void download_redirects_to_versioned_skill_url() throws Exception { + given(skillQueryService.resolveVersion( + eq("team-ai"), + eq("team-skill"), + eq("2.0.0"), + isNull(), + isNull(), + isNull(), + eq(Map.of()))) + .willReturn(new SkillQueryService.ResolvedVersionDTO( + 2L, + "team-ai", + "team-skill", + "2.0.0", + 21L, + "sha256:test", + true, + "/api/v1/skills/team-ai/team-skill/versions/2.0.0/download" + )); + + mockMvc.perform(get("/api/v1/download") + .param("slug", "team-ai--team-skill") + .param("version", "2.0.0")) + .andExpect(status().isFound()) + .andExpect(header().string("Location", "/api/v1/skills/team-ai/team-skill/versions/2.0.0/download")); + } + + private long toEpochMillis(LocalDateTime timestamp) { + return timestamp.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } +}