From 28040f36a241c4ccf25b9207e4a8a1ec981659e5 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 02:24:03 +0800 Subject: [PATCH] feat(domain): add skill publish, query, download, and tag services with tests --- server/skillhub-domain/pom.xml | 9 + .../skill/service/SkillDownloadService.java | 149 +++++++++++++ .../skill/service/SkillPublishService.java | 195 ++++++++++++++++++ .../skill/service/SkillQueryService.java | 163 +++++++++++++++ .../domain/skill/service/SkillTagService.java | 101 +++++++++ .../service/SkillDownloadServiceTest.java | 142 +++++++++++++ .../service/SkillPublishServiceTest.java | 154 ++++++++++++++ .../skill/service/SkillQueryServiceTest.java | 176 ++++++++++++++++ .../skill/service/SkillTagServiceTest.java | 159 ++++++++++++++ 9 files changed, 1248 insertions(+) create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java diff --git a/server/skillhub-domain/pom.xml b/server/skillhub-domain/pom.xml index a1746633..035ac158 100644 --- a/server/skillhub-domain/pom.xml +++ b/server/skillhub-domain/pom.xml @@ -31,6 +31,15 @@ org.yaml snakeyaml + + com.iflytek.skillhub + skillhub-storage + ${project.version} + + + com.fasterxml.jackson.core + jackson-databind + org.junit.jupiter junit-jupiter diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java new file mode 100644 index 00000000..c86c44b1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -0,0 +1,149 @@ +package com.iflytek.skillhub.domain.skill.service; + +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.skill.*; +import com.iflytek.skillhub.storage.ObjectStorageService; +import com.iflytek.skillhub.storage.ObjectMetadata; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.util.Map; + +@Service +public class SkillDownloadService { + + private final NamespaceRepository namespaceRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillTagRepository skillTagRepository; + private final ObjectStorageService objectStorageService; + private final VisibilityChecker visibilityChecker; + private final ApplicationEventPublisher eventPublisher; + + public SkillDownloadService( + NamespaceRepository namespaceRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + SkillTagRepository skillTagRepository, + ObjectStorageService objectStorageService, + VisibilityChecker visibilityChecker, + ApplicationEventPublisher eventPublisher) { + this.namespaceRepository = namespaceRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillTagRepository = skillTagRepository; + this.objectStorageService = objectStorageService; + this.visibilityChecker = visibilityChecker; + this.eventPublisher = eventPublisher; + } + + public record DownloadResult( + InputStream content, + String filename, + long contentLength, + String contentType + ) {} + + public DownloadResult downloadLatest( + String namespaceSlug, + String skillSlug, + Long currentUserId, + Map userNsRoles) { + + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + + // Visibility check + if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + throw new SecurityException("Access denied to skill: " + skillSlug); + } + + if (skill.getLatestVersionId() == null) { + throw new IllegalArgumentException("No published version available for skill: " + skillSlug); + } + + SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()) + .orElseThrow(() -> new IllegalArgumentException("Latest version not found")); + + return downloadVersion(skill, version); + } + + public DownloadResult downloadVersion( + String namespaceSlug, + String skillSlug, + String versionStr, + Long currentUserId, + Map userNsRoles) { + + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + + // Visibility check + if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + throw new SecurityException("Access denied to skill: " + skillSlug); + } + + SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), versionStr) + .orElseThrow(() -> new IllegalArgumentException("Version not found: " + versionStr)); + + return downloadVersion(skill, version); + } + + public DownloadResult downloadByTag( + String namespaceSlug, + String skillSlug, + String tagName, + Long currentUserId, + Map userNsRoles) { + + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + + // Visibility check + if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + throw new SecurityException("Access denied to skill: " + skillSlug); + } + + SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName) + .orElseThrow(() -> new IllegalArgumentException("Tag not found: " + tagName)); + + if (tag.getVersionId() == null) { + throw new IllegalArgumentException("Tag does not point to a version: " + tagName); + } + + SkillVersion version = skillVersionRepository.findById(tag.getVersionId()) + .orElseThrow(() -> new IllegalArgumentException("Version not found for tag: " + tagName)); + + return downloadVersion(skill, version); + } + + private DownloadResult downloadVersion(Skill skill, SkillVersion version) { + String storageKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); + + if (!objectStorageService.exists(storageKey)) { + throw new IllegalArgumentException("Bundle not found in storage"); + } + + ObjectMetadata metadata = objectStorageService.getMetadata(storageKey); + InputStream content = objectStorageService.getObject(storageKey); + + // Publish download event + eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + + String filename = String.format("%s-%s.zip", skill.getSlug(), version.getVersion()); + + return new DownloadResult(content, filename, metadata.size(), metadata.contentType()); + } + + private Namespace findNamespace(String slug) { + return namespaceRepository.findBySlug(slug) + .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java new file mode 100644 index 00000000..a9b15d71 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -0,0 +1,195 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.PrePublishValidator; +import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; +import com.iflytek.skillhub.domain.skill.validation.ValidationResult; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.ByteArrayInputStream; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +@Service +public class SkillPublishService { + + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillFileRepository skillFileRepository; + private final ObjectStorageService objectStorageService; + private final SkillPackageValidator skillPackageValidator; + private final SkillMetadataParser skillMetadataParser; + private final PrePublishValidator prePublishValidator; + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper; + + public SkillPublishService( + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + SkillFileRepository skillFileRepository, + ObjectStorageService objectStorageService, + SkillPackageValidator skillPackageValidator, + SkillMetadataParser skillMetadataParser, + PrePublishValidator prePublishValidator, + ApplicationEventPublisher eventPublisher, + ObjectMapper objectMapper) { + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillFileRepository = skillFileRepository; + this.objectStorageService = objectStorageService; + this.skillPackageValidator = skillPackageValidator; + this.skillMetadataParser = skillMetadataParser; + this.prePublishValidator = prePublishValidator; + this.eventPublisher = eventPublisher; + this.objectMapper = objectMapper; + } + + @Transactional + public SkillVersion publishFromEntries( + String namespaceSlug, + List entries, + Long publisherId, + SkillVisibility visibility) { + + // 1. Find namespace by slug + Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + namespaceSlug)); + + // 2. Check publisher is member + namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId) + .orElseThrow(() -> new IllegalArgumentException("Publisher is not a member of namespace: " + namespaceSlug)); + + // 3. Validate package + ValidationResult packageValidation = skillPackageValidator.validate(entries); + if (!packageValidation.passed()) { + throw new IllegalArgumentException("Package validation failed: " + String.join(", ", packageValidation.errors())); + } + + // 4. Parse SKILL.md + PackageEntry skillMd = entries.stream() + .filter(e -> e.path().equals("SKILL.md")) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("SKILL.md not found")); + + String skillMdContent = new String(skillMd.content()); + SkillMetadata metadata = skillMetadataParser.parse(skillMdContent); + + // 5. Run PrePublishValidator + PrePublishValidator.SkillPackageContext context = new PrePublishValidator.SkillPackageContext( + entries, metadata, publisherId, namespace.getId()); + ValidationResult prePublishValidation = prePublishValidator.validate(context); + if (!prePublishValidation.passed()) { + throw new IllegalArgumentException("Pre-publish validation failed: " + String.join(", ", prePublishValidation.errors())); + } + + // 6. Find or create Skill record + Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), metadata.name()) + .orElseGet(() -> { + Skill newSkill = new Skill(namespace.getId(), metadata.name(), publisherId, visibility); + newSkill.setCreatedBy(publisherId); + return skillRepository.save(newSkill); + }); + + // 7. Check version doesn't already exist + if (skillVersionRepository.findBySkillIdAndVersion(skill.getId(), metadata.version()).isPresent()) { + throw new IllegalArgumentException("Version already exists: " + metadata.version()); + } + + // 8. Create SkillVersion + SkillVersion version = new SkillVersion(skill.getId(), metadata.version(), publisherId); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setPublishedAt(LocalDateTime.now()); + + // Store metadata as JSON + try { + String metadataJson = objectMapper.writeValueAsString(metadata); + version.setParsedMetadataJson(metadataJson); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize metadata", e); + } + + version = skillVersionRepository.save(version); + + // 9. Upload each file to storage and compute SHA-256 + List skillFiles = new ArrayList<>(); + long totalSize = 0; + + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + HexFormat hexFormat = HexFormat.of(); + + for (PackageEntry entry : entries) { + String storageKey = String.format("skills/%d/%d/%s", skill.getId(), version.getId(), entry.path()); + + // Upload to storage + objectStorageService.putObject( + storageKey, + new ByteArrayInputStream(entry.content()), + entry.size(), + entry.contentType() + ); + + // Compute SHA-256 + byte[] hash = digest.digest(entry.content()); + String sha256 = hexFormat.formatHex(hash); + + // Create SkillFile record + SkillFile skillFile = new SkillFile( + version.getId(), + entry.path(), + entry.size(), + entry.contentType(), + sha256, + storageKey + ); + skillFiles.add(skillFile); + totalSize += entry.size(); + + digest.reset(); + } + } catch (Exception e) { + throw new RuntimeException("Failed to process files", e); + } + + // 10. Save SkillFile records + skillFileRepository.saveAll(skillFiles); + + // 11. Update version stats + version.setFileCount(skillFiles.size()); + version.setTotalSize(totalSize); + skillVersionRepository.save(version); + + // 12. Update skill + skill.setLatestVersionId(version.getId()); + skill.setDisplayName(metadata.name()); + skill.setSummary(metadata.description()); + skill.setUpdatedBy(publisherId); + skillRepository.save(skill); + + // 13. Publish SkillPublishedEvent + eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId)); + + // 14. Return version + return version; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java new file mode 100644 index 00000000..79cc70d8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -0,0 +1,163 @@ +package com.iflytek.skillhub.domain.skill.service; + +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.skill.*; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class SkillQueryService { + + private final NamespaceRepository namespaceRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillFileRepository skillFileRepository; + private final ObjectStorageService objectStorageService; + private final VisibilityChecker visibilityChecker; + + public SkillQueryService( + NamespaceRepository namespaceRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + SkillFileRepository skillFileRepository, + ObjectStorageService objectStorageService, + VisibilityChecker visibilityChecker) { + this.namespaceRepository = namespaceRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillFileRepository = skillFileRepository; + this.objectStorageService = objectStorageService; + this.visibilityChecker = visibilityChecker; + } + + public record SkillDetailDTO( + Long id, + String slug, + String displayName, + String summary, + String visibility, + String status, + Long downloadCount, + Integer starCount, + String latestVersion, + Long namespaceId + ) {} + + public SkillDetailDTO getSkillDetail( + String namespaceSlug, + String skillSlug, + Long currentUserId, + Map userNsRoles) { + + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + + // Visibility check + if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + throw new SecurityException("Access denied to skill: " + skillSlug); + } + + String latestVersion = null; + if (skill.getLatestVersionId() != null) { + SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); + if (version != null) { + latestVersion = version.getVersion(); + } + } + + return new SkillDetailDTO( + skill.getId(), + skill.getSlug(), + skill.getDisplayName(), + skill.getSummary(), + skill.getVisibility().name(), + skill.getStatus().name(), + skill.getDownloadCount(), + skill.getStarCount(), + latestVersion, + skill.getNamespaceId() + ); + } + + public Page listSkillsByNamespace( + String namespaceSlug, + Long currentUserId, + Map userNsRoles, + Pageable pageable) { + + Namespace namespace = findNamespace(namespaceSlug); + List allSkills = skillRepository.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE); + + // Filter by visibility + List accessibleSkills = allSkills.stream() + .filter(skill -> visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) + .collect(Collectors.toList()); + + // Manual pagination + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), accessibleSkills.size()); + List pageContent = accessibleSkills.subList(start, end); + + return new PageImpl<>(pageContent, pageable, accessibleSkills.size()); + } + + public List listFiles(String namespaceSlug, String skillSlug, String version) { + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + + SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) + .orElseThrow(() -> new IllegalArgumentException("Version not found: " + version)); + + return skillFileRepository.findByVersionId(skillVersion.getId()); + } + + public InputStream getFileContent(String namespaceSlug, String skillSlug, String version, String filePath) { + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + + SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) + .orElseThrow(() -> new IllegalArgumentException("Version not found: " + version)); + + List files = skillFileRepository.findByVersionId(skillVersion.getId()); + SkillFile file = files.stream() + .filter(f -> f.getFilePath().equals(filePath)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("File not found: " + filePath)); + + return objectStorageService.getObject(file.getStorageKey()); + } + + public Page listVersions(String namespaceSlug, String skillSlug, Pageable pageable) { + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + + List publishedVersions = skillVersionRepository.findBySkillIdAndStatus( + skill.getId(), SkillVersionStatus.PUBLISHED); + + // Manual pagination + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), publishedVersions.size()); + List pageContent = publishedVersions.subList(start, end); + + return new PageImpl<>(pageContent, pageable, publishedVersions.size()); + } + + private Namespace findNamespace(String slug) { + return namespaceRepository.findBySlug(slug) + .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java new file mode 100644 index 00000000..44ed334b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java @@ -0,0 +1,101 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class SkillTagService { + + private static final String RESERVED_TAG_LATEST = "latest"; + + private final NamespaceRepository namespaceRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillTagRepository skillTagRepository; + + public SkillTagService( + NamespaceRepository namespaceRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + SkillTagRepository skillTagRepository) { + this.namespaceRepository = namespaceRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillTagRepository = skillTagRepository; + } + + public List listTags(String namespaceSlug, String skillSlug) { + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + + return skillTagRepository.findBySkillId(skill.getId()); + } + + @Transactional + public SkillTag createOrMoveTag( + String namespaceSlug, + String skillSlug, + String tagName, + String targetVersion, + Long operatorId) { + + // Reject "latest" tag + if (RESERVED_TAG_LATEST.equalsIgnoreCase(tagName)) { + throw new IllegalArgumentException("Tag name 'latest' is reserved"); + } + + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + + // Find target version + SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), targetVersion) + .orElseThrow(() -> new IllegalArgumentException("Version not found: " + targetVersion)); + + // Target must be PUBLISHED + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + throw new IllegalArgumentException("Target version must be PUBLISHED"); + } + + // Check if tag exists + SkillTag existingTag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName).orElse(null); + + if (existingTag != null) { + // Move tag + existingTag.setVersionId(version.getId()); + return skillTagRepository.save(existingTag); + } else { + // Create new tag + SkillTag newTag = new SkillTag(skill.getId(), tagName, version.getId(), operatorId); + return skillTagRepository.save(newTag); + } + } + + @Transactional + public void deleteTag(String namespaceSlug, String skillSlug, String tagName, Long operatorId) { + // Reject "latest" tag + if (RESERVED_TAG_LATEST.equalsIgnoreCase(tagName)) { + throw new IllegalArgumentException("Tag name 'latest' is reserved and cannot be deleted"); + } + + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + + SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName) + .orElseThrow(() -> new IllegalArgumentException("Tag not found: " + tagName)); + + skillTagRepository.delete(tag); + } + + private Namespace findNamespace(String slug) { + return namespaceRepository.findBySlug(slug) + .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug)); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java new file mode 100644 index 00000000..1a8b1231 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -0,0 +1,142 @@ +package com.iflytek.skillhub.domain.skill.service; + +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.skill.*; +import com.iflytek.skillhub.storage.ObjectMetadata; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SkillDownloadServiceTest { + + @Mock + private NamespaceRepository namespaceRepository; + @Mock + private SkillRepository skillRepository; + @Mock + private SkillVersionRepository skillVersionRepository; + @Mock + private SkillTagRepository skillTagRepository; + @Mock + private ObjectStorageService objectStorageService; + @Mock + private VisibilityChecker visibilityChecker; + @Mock + private ApplicationEventPublisher eventPublisher; + + private SkillDownloadService service; + + @BeforeEach + void setUp() { + service = new SkillDownloadService( + namespaceRepository, + skillRepository, + skillVersionRepository, + skillTagRepository, + objectStorageService, + visibilityChecker, + eventPublisher + ); + } + + @Test + void testDownloadLatest_Success() throws Exception { + // Arrange + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + Long userId = 100L; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", userId); + setId(version, 10L); + String storageKey = "packages/1/10/bundle.zip"; + InputStream content = new ByteArrayInputStream("test".getBytes()); + ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + when(objectStorageService.exists(storageKey)).thenReturn(true); + when(objectStorageService.getMetadata(storageKey)).thenReturn(metadata); + when(objectStorageService.getObject(storageKey)).thenReturn(content); + + // Act + SkillDownloadService.DownloadResult result = service.downloadLatest(namespaceSlug, skillSlug, userId, userNsRoles); + + // Assert + assertNotNull(result); + assertEquals("test-skill-1.0.0.zip", result.filename()); + assertEquals(1000L, result.contentLength()); + verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadByTag_Success() throws Exception { + // Arrange + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String tagName = "stable"; + Long userId = 100L; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + SkillTag tag = new SkillTag(1L, tagName, 10L, userId); + SkillVersion version = new SkillVersion(1L, "1.0.0", userId); + setId(version, 10L); + String storageKey = "packages/1/10/bundle.zip"; + InputStream content = new ByteArrayInputStream("test".getBytes()); + ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag)); + when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + when(objectStorageService.exists(storageKey)).thenReturn(true); + when(objectStorageService.getMetadata(storageKey)).thenReturn(metadata); + when(objectStorageService.getObject(storageKey)).thenReturn(content); + + // Act + SkillDownloadService.DownloadResult result = service.downloadByTag(namespaceSlug, skillSlug, tagName, userId, userNsRoles); + + // Assert + assertNotNull(result); + assertEquals("test-skill-1.0.0.zip", result.filename()); + verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); + } + + private void setId(Object entity, Long id) throws Exception { + Field idField = entity.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java new file mode 100644 index 00000000..792d00fa --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -0,0 +1,154 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.PrePublishValidator; +import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; +import com.iflytek.skillhub.domain.skill.validation.ValidationResult; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.junit.jupiter.api.BeforeEach; +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.context.ApplicationEventPublisher; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SkillPublishServiceTest { + + @Mock + private NamespaceRepository namespaceRepository; + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private SkillRepository skillRepository; + @Mock + private SkillVersionRepository skillVersionRepository; + @Mock + private SkillFileRepository skillFileRepository; + @Mock + private ObjectStorageService objectStorageService; + @Mock + private SkillPackageValidator skillPackageValidator; + @Mock + private SkillMetadataParser skillMetadataParser; + @Mock + private PrePublishValidator prePublishValidator; + @Mock + private ApplicationEventPublisher eventPublisher; + + private SkillPublishService service; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + service = new SkillPublishService( + namespaceRepository, + namespaceMemberRepository, + skillRepository, + skillVersionRepository, + skillFileRepository, + objectStorageService, + skillPackageValidator, + skillMetadataParser, + prePublishValidator, + eventPublisher, + objectMapper + ); + } + + @Test + void testPublishFromEntries_Success() throws Exception { + // Arrange + String namespaceSlug = "test-ns"; + Long publisherId = 100L; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + PackageEntry file1 = new PackageEntry("file1.txt", "content".getBytes(), 7, "text/plain"); + List entries = List.of(skillMd, file1); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 1L); + SkillVersion version = new SkillVersion(1L, "1.0.0", publisherId); + setId(version, 10L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any())).thenReturn(version); + when(skillRepository.save(any())).thenReturn(skill); + + // Act + SkillVersion result = service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC); + + // Assert + assertNotNull(result); + verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); + verify(skillFileRepository).saveAll(anyList()); + verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString()); + } + + @Test + void testPublishFromEntries_NamespaceNotFound() { + // Arrange + String namespaceSlug = "nonexistent"; + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(IllegalArgumentException.class, () -> + service.publishFromEntries(namespaceSlug, List.of(), 100L, SkillVisibility.PUBLIC) + ); + } + + @Test + void testPublishFromEntries_NotAMember() throws Exception { + // Arrange + String namespaceSlug = "test-ns"; + Long publisherId = 100L; + Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + setId(namespace, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(IllegalArgumentException.class, () -> + service.publishFromEntries(namespaceSlug, List.of(), publisherId, SkillVisibility.PUBLIC) + ); + } + + private void setId(Object entity, Long id) throws Exception { + Field idField = entity.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java new file mode 100644 index 00000000..bdead154 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -0,0 +1,176 @@ +package com.iflytek.skillhub.domain.skill.service; + +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.skill.*; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SkillQueryServiceTest { + + @Mock + private NamespaceRepository namespaceRepository; + @Mock + private SkillRepository skillRepository; + @Mock + private SkillVersionRepository skillVersionRepository; + @Mock + private SkillFileRepository skillFileRepository; + @Mock + private ObjectStorageService objectStorageService; + @Mock + private VisibilityChecker visibilityChecker; + + private SkillQueryService service; + + @BeforeEach + void setUp() { + service = new SkillQueryService( + namespaceRepository, + skillRepository, + skillVersionRepository, + skillFileRepository, + objectStorageService, + visibilityChecker + ); + } + + @Test + void testGetSkillDetail_Success() throws Exception { + // Arrange + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + Long userId = 100L; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setDisplayName("Test Skill"); + skill.setSummary("Test Summary"); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", userId); + setId(version, 10L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + + // Act + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + // Assert + assertNotNull(result); + assertEquals(skillSlug, result.slug()); + assertEquals("Test Skill", result.displayName()); + assertEquals("1.0.0", result.latestVersion()); + } + + @Test + void testGetSkillDetail_AccessDenied() throws Exception { + // Arrange + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + Long userId = 100L; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, 200L, SkillVisibility.PRIVATE); + setId(skill, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false); + + // Act & Assert + assertThrows(SecurityException.class, () -> + service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles) + ); + } + + @Test + void testListSkillsByNamespace() throws Exception { + // Arrange + String namespaceSlug = "test-ns"; + Long userId = 100L; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + Pageable pageable = PageRequest.of(0, 10); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + setId(namespace, 1L); + Skill skill1 = new Skill(1L, "skill1", userId, SkillVisibility.PUBLIC); + setId(skill1, 1L); + Skill skill2 = new Skill(1L, "skill2", userId, SkillVisibility.PRIVATE); + setId(skill2, 2L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)).thenReturn(List.of(skill1, skill2)); + when(visibilityChecker.canAccess(skill1, userId, userNsRoles)).thenReturn(true); + when(visibilityChecker.canAccess(skill2, userId, userNsRoles)).thenReturn(false); + + // Act + Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); + + // Assert + assertEquals(1, result.getTotalElements()); + assertEquals("skill1", result.getContent().get(0).getSlug()); + } + + @Test + void testListFiles() throws Exception { + // Arrange + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.0.0"; + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, 100L, SkillVisibility.PUBLIC); + setId(skill, 1L); + SkillVersion skillVersion = new SkillVersion(1L, version, 100L); + setId(skillVersion, 1L); + SkillFile file1 = new SkillFile(1L, "file1.txt", 100L, "text/plain", "hash1", "key1"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); + when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1)); + + // Act + List result = service.listFiles(namespaceSlug, skillSlug, version); + + // Assert + assertEquals(1, result.size()); + assertEquals("file1.txt", result.get(0).getFilePath()); + } + + private void setId(Object entity, Long id) throws Exception { + Field idField = entity.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java new file mode 100644 index 00000000..e597670f --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java @@ -0,0 +1,159 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SkillTagServiceTest { + + @Mock + private NamespaceRepository namespaceRepository; + @Mock + private SkillRepository skillRepository; + @Mock + private SkillVersionRepository skillVersionRepository; + @Mock + private SkillTagRepository skillTagRepository; + + private SkillTagService service; + + @BeforeEach + void setUp() { + service = new SkillTagService( + namespaceRepository, + skillRepository, + skillVersionRepository, + skillTagRepository + ); + } + + @Test + void testCreateTag_Success() throws Exception { + // Arrange + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String tagName = "stable"; + String targetVersion = "1.0.0"; + Long operatorId = 100L; + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, operatorId, SkillVisibility.PUBLIC); + setId(skill, 1L); + SkillVersion version = new SkillVersion(1L, targetVersion, operatorId); + setId(version, 1L); + version.setStatus(SkillVersionStatus.PUBLISHED); + SkillTag tag = new SkillTag(1L, tagName, 1L, operatorId); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(1L, targetVersion)).thenReturn(Optional.of(version)); + when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.empty()); + when(skillTagRepository.save(any())).thenReturn(tag); + + // Act + SkillTag result = service.createOrMoveTag(namespaceSlug, skillSlug, tagName, targetVersion, operatorId); + + // Assert + assertNotNull(result); + verify(skillTagRepository).save(any(SkillTag.class)); + } + + @Test + void testCreateTag_RejectLatest() { + // Arrange + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String tagName = "latest"; + String targetVersion = "1.0.0"; + Long operatorId = 100L; + + // Act & Assert + assertThrows(IllegalArgumentException.class, () -> + service.createOrMoveTag(namespaceSlug, skillSlug, tagName, targetVersion, operatorId) + ); + } + + @Test + void testDeleteTag_Success() throws Exception { + // Arrange + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String tagName = "stable"; + Long operatorId = 100L; + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, operatorId, SkillVisibility.PUBLIC); + setId(skill, 1L); + SkillTag tag = new SkillTag(1L, tagName, 1L, operatorId); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag)); + + // Act + service.deleteTag(namespaceSlug, skillSlug, tagName, operatorId); + + // Assert + verify(skillTagRepository).delete(tag); + } + + @Test + void testDeleteTag_RejectLatest() { + // Arrange + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String tagName = "latest"; + Long operatorId = 100L; + + // Act & Assert + assertThrows(IllegalArgumentException.class, () -> + service.deleteTag(namespaceSlug, skillSlug, tagName, operatorId) + ); + } + + @Test + void testListTags() throws Exception { + // Arrange + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, 100L, SkillVisibility.PUBLIC); + setId(skill, 1L); + SkillTag tag1 = new SkillTag(1L, "stable", 1L, 100L); + SkillTag tag2 = new SkillTag(1L, "beta", 2L, 100L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(skillTagRepository.findBySkillId(1L)).thenReturn(List.of(tag1, tag2)); + + // Act + List result = service.listTags(namespaceSlug, skillSlug); + + // Assert + assertEquals(2, result.size()); + } + + private void setId(Object entity, Long id) throws Exception { + Field idField = entity.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } +}