feat(domain): add skill publish, query, download, and tag services with tests

This commit is contained in:
vsxd 2026-03-12 02:24:03 +08:00
parent c9f9ef03f2
commit 28040f36a2
9 changed files with 1248 additions and 0 deletions

View file

@ -31,6 +31,15 @@
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
</dependency>
<dependency>
<groupId>com.iflytek.skillhub</groupId>
<artifactId>skillhub-storage</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>

View file

@ -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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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));
}
}

View file

@ -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<PackageEntry> 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<SkillFile> 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;
}
}

View file

@ -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<Long, NamespaceRole> 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<Skill> listSkillsByNamespace(
String namespaceSlug,
Long currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Pageable pageable) {
Namespace namespace = findNamespace(namespaceSlug);
List<Skill> allSkills = skillRepository.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE);
// Filter by visibility
List<Skill> 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<Skill> pageContent = accessibleSkills.subList(start, end);
return new PageImpl<>(pageContent, pageable, accessibleSkills.size());
}
public List<SkillFile> 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<SkillFile> 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<SkillVersion> 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<SkillVersion> publishedVersions = skillVersionRepository.findBySkillIdAndStatus(
skill.getId(), SkillVersionStatus.PUBLISHED);
// Manual pagination
int start = (int) pageable.getOffset();
int end = Math.min(start + pageable.getPageSize(), publishedVersions.size());
List<SkillVersion> 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));
}
}

View file

@ -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<SkillTag> 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));
}
}

View file

@ -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<Long, NamespaceRole> 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<Long, NamespaceRole> 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);
}
}

View file

@ -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<PackageEntry> 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);
}
}

View file

@ -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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Skill> 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<SkillFile> 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);
}
}

View file

@ -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<SkillTag> 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);
}
}