feat(domain): update entities, services, repositories, and add shared exceptions

This commit is contained in:
vsxd 2026-03-12 17:46:48 +08:00
parent 9d7282221c
commit cfcdb545f0
47 changed files with 1012 additions and 338 deletions

View file

@ -1,3 +1,3 @@
package com.iflytek.skillhub.domain.event;
public record SkillPublishedEvent(Long skillId, Long versionId, Long publisherId) {}
public record SkillPublishedEvent(Long skillId, Long versionId, String publisherId) {}

View file

@ -31,7 +31,7 @@ public class Namespace {
private String avatarUrl;
@Column(name = "created_by")
private Long createdBy;
private String createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@ -41,7 +41,7 @@ public class Namespace {
protected Namespace() {}
public Namespace(String slug, String displayName, Long createdBy) {
public Namespace(String slug, String displayName, String createdBy) {
this.slug = slug;
this.displayName = displayName;
this.createdBy = createdBy;
@ -69,7 +69,7 @@ public class Namespace {
public void setType(NamespaceType type) { this.type = type; }
public String getAvatarUrl() { return avatarUrl; }
public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; }
public Long getCreatedBy() { return createdBy; }
public String getCreatedBy() { return createdBy; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}

View file

@ -15,7 +15,7 @@ public class NamespaceMember {
private Long namespaceId;
@Column(name = "user_id", nullable = false)
private Long userId;
private String userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
@ -29,7 +29,7 @@ public class NamespaceMember {
protected NamespaceMember() {}
public NamespaceMember(Long namespaceId, Long userId, NamespaceRole role) {
public NamespaceMember(Long namespaceId, String userId, NamespaceRole role) {
this.namespaceId = namespaceId;
this.userId = userId;
this.role = role;
@ -49,8 +49,8 @@ public class NamespaceMember {
public Long getId() { return id; }
public Long getNamespaceId() { return namespaceId; }
public void setNamespaceId(Long namespaceId) { this.namespaceId = namespaceId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public NamespaceRole getRole() { return role; }
public void setRole(NamespaceRole role) { this.role = role; }
public LocalDateTime getCreatedAt() { return createdAt; }

View file

@ -7,9 +7,9 @@ import java.util.List;
import java.util.Optional;
public interface NamespaceMemberRepository {
Optional<NamespaceMember> findByNamespaceIdAndUserId(Long namespaceId, Long userId);
List<NamespaceMember> findByUserId(Long userId);
Optional<NamespaceMember> findByNamespaceIdAndUserId(Long namespaceId, String userId);
List<NamespaceMember> findByUserId(String userId);
Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable);
NamespaceMember save(NamespaceMember member);
void deleteByNamespaceIdAndUserId(Long namespaceId, Long userId);
void deleteByNamespaceIdAndUserId(Long namespaceId, String userId);
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@ -11,19 +12,24 @@ import java.util.Optional;
public class NamespaceMemberService {
private final NamespaceMemberRepository namespaceMemberRepository;
private final NamespaceService namespaceService;
public NamespaceMemberService(NamespaceMemberRepository namespaceMemberRepository) {
public NamespaceMemberService(NamespaceMemberRepository namespaceMemberRepository,
NamespaceService namespaceService) {
this.namespaceMemberRepository = namespaceMemberRepository;
this.namespaceService = namespaceService;
}
@Transactional
public NamespaceMember addMember(Long namespaceId, Long userId, NamespaceRole role) {
public NamespaceMember addMember(Long namespaceId, String userId, NamespaceRole role, String operatorUserId) {
namespaceService.assertAdminOrOwner(namespaceId, operatorUserId);
if (role == NamespaceRole.OWNER) {
throw new IllegalArgumentException("Cannot directly assign OWNER role");
throw new DomainBadRequestException("error.namespace.member.owner.assignDirect");
}
if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId).isPresent()) {
throw new IllegalArgumentException("User is already a member of this namespace");
throw new DomainBadRequestException("error.namespace.member.alreadyExists");
}
NamespaceMember member = new NamespaceMember(namespaceId, userId, role);
@ -31,41 +37,45 @@ public class NamespaceMemberService {
}
@Transactional
public void removeMember(Long namespaceId, Long userId) {
public void removeMember(Long namespaceId, String userId, String operatorUserId) {
namespaceService.assertAdminOrOwner(namespaceId, operatorUserId);
NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
.orElseThrow(() -> new IllegalArgumentException("Member not found"));
.orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound"));
if (member.getRole() == NamespaceRole.OWNER) {
throw new IllegalArgumentException("Cannot remove OWNER from namespace");
throw new DomainBadRequestException("error.namespace.member.owner.remove");
}
namespaceMemberRepository.deleteByNamespaceIdAndUserId(namespaceId, userId);
}
@Transactional
public NamespaceMember updateMemberRole(Long namespaceId, Long userId, NamespaceRole newRole) {
public NamespaceMember updateMemberRole(Long namespaceId, String userId, NamespaceRole newRole, String operatorUserId) {
namespaceService.assertAdminOrOwner(namespaceId, operatorUserId);
if (newRole == NamespaceRole.OWNER) {
throw new IllegalArgumentException("Cannot set OWNER role directly. Use transferOwnership instead");
throw new DomainBadRequestException("error.namespace.member.owner.setDirect");
}
NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
.orElseThrow(() -> new IllegalArgumentException("Member not found"));
.orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound"));
member.setRole(newRole);
return namespaceMemberRepository.save(member);
}
@Transactional
public void transferOwnership(Long namespaceId, Long currentOwnerId, Long newOwnerId) {
public void transferOwnership(Long namespaceId, String currentOwnerId, String newOwnerId) {
NamespaceMember currentOwner = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId)
.orElseThrow(() -> new IllegalArgumentException("Current owner not found"));
.orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.current.notFound"));
if (currentOwner.getRole() != NamespaceRole.OWNER) {
throw new IllegalArgumentException("User is not the current owner");
throw new DomainBadRequestException("error.namespace.owner.current.invalid");
}
NamespaceMember newOwner = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, newOwnerId)
.orElseThrow(() -> new IllegalArgumentException("New owner not found in namespace"));
.orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.new.notFound"));
currentOwner.setRole(NamespaceRole.ADMIN);
newOwner.setRole(NamespaceRole.OWNER);
@ -74,7 +84,7 @@ public class NamespaceMemberService {
namespaceMemberRepository.save(newOwner);
}
public Optional<NamespaceRole> getMemberRole(Long namespaceId, Long userId) {
public Optional<NamespaceRole> getMemberRole(Long namespaceId, String userId) {
return namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
.map(NamespaceMember::getRole);
}

View file

@ -3,10 +3,12 @@ package com.iflytek.skillhub.domain.namespace;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
public interface NamespaceRepository {
Optional<Namespace> findById(Long id);
List<Namespace> findByIdIn(List<Long> ids);
Optional<Namespace> findBySlug(String slug);
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
Namespace save(Namespace namespace);

View file

@ -1,5 +1,7 @@
package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -16,11 +18,11 @@ public class NamespaceService {
}
@Transactional
public Namespace createNamespace(String slug, String displayName, String description, Long creatorUserId) {
public Namespace createNamespace(String slug, String displayName, String description, String creatorUserId) {
SlugValidator.validate(slug);
if (namespaceRepository.findBySlug(slug).isPresent()) {
throw new IllegalArgumentException("Namespace with slug '" + slug + "' already exists");
throw new DomainBadRequestException("error.namespace.slug.exists", slug);
}
Namespace namespace = new Namespace(slug, displayName, creatorUserId);
@ -35,9 +37,11 @@ public class NamespaceService {
}
@Transactional
public Namespace updateNamespace(Long namespaceId, String displayName, String description, String avatarUrl) {
public Namespace updateNamespace(Long namespaceId, String displayName, String description, String avatarUrl,
String operatorUserId) {
Namespace namespace = namespaceRepository.findById(namespaceId)
.orElseThrow(() -> new IllegalArgumentException("Namespace not found with id: " + namespaceId));
.orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId));
assertAdminOrOwner(namespaceId, operatorUserId);
if (displayName != null) {
namespace.setDisplayName(displayName);
@ -54,6 +58,15 @@ public class NamespaceService {
public Namespace getNamespaceBySlug(String slug) {
return namespaceRepository.findBySlug(slug)
.orElseThrow(() -> new IllegalArgumentException("Namespace not found with slug: " + slug));
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
}
void assertAdminOrOwner(Long namespaceId, String userId) {
NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
.map(NamespaceMember::getRole)
.orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required"));
if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) {
throw new DomainForbiddenException("error.namespace.admin.required");
}
}
}

View file

@ -1,5 +1,7 @@
package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import java.util.Set;
import java.util.regex.Pattern;
@ -15,22 +17,32 @@ public class SlugValidator {
public static void validate(String slug) {
if (slug == null || slug.isBlank()) {
throw new IllegalArgumentException("Slug cannot be null or blank");
throw new DomainBadRequestException("error.slug.blank");
}
if (slug.length() < MIN_LENGTH || slug.length() > MAX_LENGTH) {
throw new IllegalArgumentException(
String.format("Slug length must be between %d and %d characters", MIN_LENGTH, MAX_LENGTH));
throw new DomainBadRequestException("error.slug.length", MIN_LENGTH, MAX_LENGTH);
}
if (!SLUG_PATTERN.matcher(slug).matches()) {
throw new IllegalArgumentException(
"Slug must contain only lowercase alphanumeric characters and hyphens, " +
"and must start and end with an alphanumeric character");
throw new DomainBadRequestException("error.slug.pattern");
}
if (slug.contains("--")) {
throw new IllegalArgumentException("Slug cannot contain consecutive hyphens");
throw new DomainBadRequestException("error.slug.doubleHyphen");
}
if (RESERVED_SLUGS.contains(slug)) {
throw new IllegalArgumentException("Slug '" + slug + "' is reserved and cannot be used");
throw new DomainBadRequestException("error.slug.reserved", slug);
}
}
public static String slugify(String raw) {
if (raw == null) {
throw new DomainBadRequestException("error.slug.blank");
}
String slug = raw.trim().toLowerCase()
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("^-+", "")
.replaceAll("-+$", "")
.replaceAll("-{2,}", "-");
validate(slug);
return slug;
}
}

View file

@ -32,10 +32,10 @@ public class PromotionRequest {
private Integer version = 1;
@Column(name = "submitted_by", nullable = false)
private Long submittedBy;
private String submittedBy;
@Column(name = "reviewed_by")
private Long reviewedBy;
private String reviewedBy;
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@ -49,7 +49,7 @@ public class PromotionRequest {
protected PromotionRequest() {}
public PromotionRequest(Long sourceSkillId, Long sourceVersionId,
Long targetNamespaceId, Long submittedBy) {
Long targetNamespaceId, String submittedBy) {
this.sourceSkillId = sourceSkillId;
this.sourceVersionId = sourceVersionId;
this.targetNamespaceId = targetNamespaceId;
@ -76,11 +76,11 @@ public class PromotionRequest {
public Integer getVersion() { return version; }
public Long getSubmittedBy() { return submittedBy; }
public String getSubmittedBy() { return submittedBy; }
public Long getReviewedBy() { return reviewedBy; }
public String getReviewedBy() { return reviewedBy; }
public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; }
public void setReviewedBy(String reviewedBy) { this.reviewedBy = reviewedBy; }
public String getReviewComment() { return reviewComment; }

View file

@ -9,6 +9,6 @@ public interface PromotionRequestRepository {
Optional<PromotionRequest> findById(Long id);
Optional<PromotionRequest> findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status);
Page<PromotionRequest> findByStatus(ReviewTaskStatus status, Pageable pageable);
int updateStatusWithVersion(Long id, ReviewTaskStatus status, Long reviewedBy,
int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy,
String reviewComment, Long targetSkillId, Integer expectedVersion);
}

View file

@ -46,7 +46,7 @@ public class PromotionService {
@Transactional
public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId,
Long targetNamespaceId, Long userId) {
Long targetNamespaceId, String userId) {
Skill sourceSkill = skillRepository.findById(sourceSkillId)
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId));
@ -78,7 +78,7 @@ public class PromotionService {
}
@Transactional
public PromotionRequest approvePromotion(Long promotionId, Long reviewerId,
public PromotionRequest approvePromotion(Long promotionId, String reviewerId,
String comment, Set<String> platformRoles) {
PromotionRequest request = promotionRequestRepository.findById(promotionId)
.orElseThrow(() -> new DomainNotFoundException("promotion.not_found", promotionId));
@ -149,7 +149,7 @@ public class PromotionService {
}
@Transactional
public PromotionRequest rejectPromotion(Long promotionId, Long reviewerId,
public PromotionRequest rejectPromotion(Long promotionId, String reviewerId,
String comment, Set<String> platformRoles) {
PromotionRequest request = promotionRequestRepository.findById(promotionId)
.orElseThrow(() -> new DomainNotFoundException("promotion.not_found", promotionId));

View file

@ -21,7 +21,7 @@ public class ReviewPermissionChecker {
* @return true if the user is allowed to review
*/
public boolean canReview(ReviewTask task,
Long userId,
String userId,
NamespaceType namespaceType,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
@ -49,7 +49,7 @@ public class ReviewPermissionChecker {
*/
public boolean canReviewPromotion(
PromotionRequest request,
Long userId,
String userId,
Set<String> platformRoles) {
if (request.getSubmittedBy().equals(userId)) {
return false;

View file

@ -48,7 +48,7 @@ public class ReviewService {
}
@Transactional
public ReviewTask submitReview(Long skillVersionId, Long namespaceId, Long userId) {
public ReviewTask submitReview(Long skillVersionId, Long namespaceId, String userId) {
SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId)
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId));
@ -68,7 +68,7 @@ public class ReviewService {
}
@Transactional
public ReviewTask approveReview(Long reviewTaskId, Long reviewerId, String comment,
public ReviewTask approveReview(Long reviewTaskId, String reviewerId, String comment,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
ReviewTask task = reviewTaskRepository.findById(reviewTaskId)
@ -111,7 +111,7 @@ public class ReviewService {
}
@Transactional
public ReviewTask rejectReview(Long reviewTaskId, Long reviewerId, String comment,
public ReviewTask rejectReview(Long reviewTaskId, String reviewerId, String comment,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
ReviewTask task = reviewTaskRepository.findById(reviewTaskId)
@ -144,7 +144,7 @@ public class ReviewService {
}
@Transactional
public void withdrawReview(Long skillVersionId, Long userId) {
public void withdrawReview(Long skillVersionId, String userId) {
ReviewTask task = reviewTaskRepository.findBySkillVersionIdAndStatus(
skillVersionId, ReviewTaskStatus.PENDING)
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found_for_version", skillVersionId));

View file

@ -26,10 +26,10 @@ public class ReviewTask {
private Integer version = 1;
@Column(name = "submitted_by", nullable = false)
private Long submittedBy;
private String submittedBy;
@Column(name = "reviewed_by")
private Long reviewedBy;
private String reviewedBy;
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@ -43,7 +43,7 @@ public class ReviewTask {
protected ReviewTask() {}
public ReviewTask(Long skillVersionId, Long namespaceId,
Long submittedBy) {
String submittedBy) {
this.skillVersionId = skillVersionId;
this.namespaceId = namespaceId;
this.submittedBy = submittedBy;
@ -61,11 +61,11 @@ public class ReviewTask {
public Integer getVersion() { return version; }
public Long getSubmittedBy() { return submittedBy; }
public String getSubmittedBy() { return submittedBy; }
public Long getReviewedBy() { return reviewedBy; }
public String getReviewedBy() { return reviewedBy; }
public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; }
public void setReviewedBy(String reviewedBy) { this.reviewedBy = reviewedBy; }
public String getReviewComment() { return reviewComment; }

View file

@ -9,8 +9,8 @@ public interface ReviewTaskRepository {
Optional<ReviewTask> findById(Long id);
Optional<ReviewTask> findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status);
Page<ReviewTask> findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable);
Page<ReviewTask> findBySubmittedByAndStatus(Long submittedBy, ReviewTaskStatus status, Pageable pageable);
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
void delete(ReviewTask reviewTask);
int updateStatusWithVersion(Long id, ReviewTaskStatus status, Long reviewedBy,
int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy,
String reviewComment, Integer expectedVersion);
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.domain.shared.exception;
public class DomainBadRequestException extends LocalizedDomainException {
public DomainBadRequestException(String messageCode, Object... messageArgs) {
super(messageCode, messageArgs);
}
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.domain.shared.exception;
public class DomainForbiddenException extends LocalizedDomainException {
public DomainForbiddenException(String messageCode, Object... messageArgs) {
super(messageCode, messageArgs);
}
}

View file

@ -0,0 +1,21 @@
package com.iflytek.skillhub.domain.shared.exception;
public abstract class LocalizedDomainException extends RuntimeException {
private final String messageCode;
private final Object[] messageArgs;
protected LocalizedDomainException(String messageCode, Object... messageArgs) {
super(messageCode);
this.messageCode = messageCode;
this.messageArgs = messageArgs == null ? new Object[0] : messageArgs;
}
public String messageCode() {
return messageCode;
}
public Object[] messageArgs() {
return messageArgs.clone();
}
}

View file

@ -25,7 +25,7 @@ public class Skill {
private String summary;
@Column(name = "owner_id", nullable = false)
private Long ownerId;
private String ownerId;
@Column(name = "source_skill_id")
private Long sourceSkillId;
@ -54,13 +54,13 @@ public class Skill {
private Integer ratingCount = 0;
@Column(name = "created_by")
private Long createdBy;
private String createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_by")
private Long updatedBy;
private String updatedBy;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@ -68,7 +68,7 @@ public class Skill {
protected Skill() {
}
public Skill(Long namespaceId, String slug, Long ownerId, SkillVisibility visibility) {
public Skill(Long namespaceId, String slug, String ownerId, SkillVisibility visibility) {
this.namespaceId = namespaceId;
this.slug = slug;
this.ownerId = ownerId;
@ -108,7 +108,7 @@ public class Skill {
return summary;
}
public Long getOwnerId() {
public String getOwnerId() {
return ownerId;
}
@ -144,7 +144,7 @@ public class Skill {
return ratingCount;
}
public Long getCreatedBy() {
public String getCreatedBy() {
return createdBy;
}
@ -152,7 +152,7 @@ public class Skill {
return createdAt;
}
public Long getUpdatedBy() {
public String getUpdatedBy() {
return updatedBy;
}
@ -185,11 +185,11 @@ public class Skill {
this.latestVersionId = latestVersionId;
}
public void setCreatedBy(Long createdBy) {
public void setCreatedBy(String createdBy) {
this.createdBy = createdBy;
}
public void setUpdatedBy(Long updatedBy) {
public void setUpdatedBy(String updatedBy) {
this.updatedBy = updatedBy;
}
}

View file

@ -5,6 +5,6 @@ import java.util.List;
public interface SkillFileRepository {
List<SkillFile> findByVersionId(Long versionId);
SkillFile save(SkillFile file);
void saveAll(List<SkillFile> files);
<S extends SkillFile> List<S> saveAll(Iterable<S> files);
void deleteByVersionId(Long versionId);
}

View file

@ -5,9 +5,11 @@ import java.util.Optional;
public interface SkillRepository {
Optional<Skill> findById(Long id);
List<Skill> findByIdIn(List<Long> ids);
List<Skill> findAll();
Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status);
Skill save(Skill skill);
List<Skill> findByOwnerId(Long ownerId);
List<Skill> findByOwnerId(String ownerId);
void incrementDownloadCount(Long skillId);
}

View file

@ -21,7 +21,7 @@ public class SkillTag {
private Long versionId;
@Column(name = "created_by", nullable = false)
private Long createdBy;
private String createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@ -32,7 +32,7 @@ public class SkillTag {
protected SkillTag() {
}
public SkillTag(Long skillId, String tagName, Long versionId, Long createdBy) {
public SkillTag(Long skillId, String tagName, Long versionId, String createdBy) {
this.skillId = skillId;
this.tagName = tagName;
this.versionId = versionId;
@ -67,7 +67,7 @@ public class SkillTag {
return versionId;
}
public Long getCreatedBy() {
public String getCreatedBy() {
return createdBy;
}

View file

@ -2,6 +2,8 @@ package com.iflytek.skillhub.domain.skill;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@Entity
@Table(name = "skill_version")
@ -14,7 +16,7 @@ public class SkillVersion {
@Column(name = "skill_id", nullable = false)
private Long skillId;
@Column(nullable = false, length = 50)
@Column(nullable = false, length = 64)
private String version;
@Enumerated(EnumType.STRING)
@ -24,10 +26,12 @@ public class SkillVersion {
@Column(columnDefinition = "TEXT")
private String changelog;
@Column(name = "parsed_metadata_json", columnDefinition = "TEXT")
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "parsed_metadata_json", columnDefinition = "jsonb")
private String parsedMetadataJson;
@Column(name = "manifest_json", columnDefinition = "TEXT")
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "manifest_json", columnDefinition = "jsonb")
private String manifestJson;
@Column(name = "file_count", nullable = false)
@ -40,7 +44,7 @@ public class SkillVersion {
private LocalDateTime publishedAt;
@Column(name = "created_by", nullable = false)
private Long createdBy;
private String createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@ -48,7 +52,7 @@ public class SkillVersion {
protected SkillVersion() {
}
public SkillVersion(Long skillId, String version, Long createdBy) {
public SkillVersion(Long skillId, String version, String createdBy) {
this.skillId = skillId;
this.version = version;
this.createdBy = createdBy;
@ -101,7 +105,7 @@ public class SkillVersion {
return publishedAt;
}
public Long getCreatedBy() {
public String getCreatedBy() {
return createdBy;
}

View file

@ -5,6 +5,7 @@ import java.util.Optional;
public interface SkillVersionRepository {
Optional<SkillVersion> findById(Long id);
List<SkillVersion> findByIdIn(List<Long> ids);
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
List<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status);
SkillVersion save(SkillVersion version);

View file

@ -6,7 +6,7 @@ import java.util.Map;
public class VisibilityChecker {
public boolean canAccess(Skill skill, Long currentUserId, Map<Long, NamespaceRole> userNamespaceRoles) {
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNamespaceRoles) {
return switch (skill.getVisibility()) {
case PUBLIC -> true;
case NAMESPACE_ONLY -> userNamespaceRoles.containsKey(skill.getNamespaceId());
@ -14,7 +14,7 @@ public class VisibilityChecker {
};
}
private boolean isOwner(Skill skill, Long currentUserId) {
private boolean isOwner(Skill skill, String currentUserId) {
return currentUserId != null && skill.getOwnerId().equals(currentUserId);
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.skill.metadata;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.yaml.snakeyaml.Yaml;
import java.util.Map;
@ -10,23 +11,23 @@ public class SkillMetadataParser {
public SkillMetadata parse(String content) {
if (content == null || content.isBlank()) {
throw new IllegalArgumentException("Content cannot be null or empty");
throw new DomainBadRequestException("error.skill.metadata.content.empty");
}
String trimmedContent = content.trim();
if (!trimmedContent.startsWith(FRONTMATTER_DELIMITER)) {
throw new IllegalArgumentException("Missing frontmatter: content must start with '---'");
throw new DomainBadRequestException("error.skill.metadata.frontmatter.missingStart");
}
int firstDelimiterEnd = trimmedContent.indexOf('\n', FRONTMATTER_DELIMITER.length());
if (firstDelimiterEnd == -1) {
throw new IllegalArgumentException("Missing frontmatter: no content after first '---'");
throw new DomainBadRequestException("error.skill.metadata.frontmatter.missingContent");
}
int secondDelimiterStart = trimmedContent.indexOf(FRONTMATTER_DELIMITER, firstDelimiterEnd + 1);
if (secondDelimiterStart == -1) {
throw new IllegalArgumentException("Missing frontmatter: no closing '---' found");
throw new DomainBadRequestException("error.skill.metadata.frontmatter.missingEnd");
}
String yamlContent = trimmedContent.substring(firstDelimiterEnd + 1, secondDelimiterStart).trim();
@ -37,18 +38,20 @@ public class SkillMetadataParser {
Yaml yaml = new Yaml();
Object parsed = yaml.load(yamlContent);
if (!(parsed instanceof Map)) {
throw new IllegalArgumentException("Invalid YAML: frontmatter must be a map");
throw new DomainBadRequestException("error.skill.metadata.yaml.notMap");
}
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) parsed;
frontmatter = map;
} catch (DomainBadRequestException e) {
throw e;
} catch (Exception e) {
throw new IllegalArgumentException("Invalid YAML in frontmatter: " + e.getMessage(), e);
throw new DomainBadRequestException("error.skill.metadata.yaml.invalid", e.getMessage());
}
String name = extractRequiredField(frontmatter, "name");
String description = extractRequiredField(frontmatter, "description");
String version = extractRequiredField(frontmatter, "version");
String version = extractOptionalField(frontmatter, "version");
return new SkillMetadata(name, description, version, body, frontmatter);
}
@ -56,8 +59,13 @@ public class SkillMetadataParser {
private String extractRequiredField(Map<String, Object> frontmatter, String fieldName) {
Object value = frontmatter.get(fieldName);
if (value == null) {
throw new IllegalArgumentException("Missing required field: " + fieldName);
throw new DomainBadRequestException("error.skill.metadata.requiredField.missing", fieldName);
}
return value.toString();
}
private String extractOptionalField(Map<String, Object> frontmatter, String fieldName) {
Object value = frontmatter.get(fieldName);
return value == null ? null : value.toString();
}
}

View file

@ -4,6 +4,8 @@ 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.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.*;
import com.iflytek.skillhub.storage.ObjectStorageService;
import com.iflytek.skillhub.storage.ObjectMetadata;
@ -51,24 +53,24 @@ public class SkillDownloadService {
public DownloadResult downloadLatest(
String namespaceSlug,
String skillSlug,
Long currentUserId,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug));
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
// Visibility check
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
throw new SecurityException("Access denied to skill: " + skillSlug);
throw new DomainForbiddenException("error.skill.access.denied", skillSlug);
}
if (skill.getLatestVersionId() == null) {
throw new IllegalArgumentException("No published version available for skill: " + skillSlug);
throw new DomainBadRequestException("error.skill.version.latest.unavailable", skillSlug);
}
SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId())
.orElseThrow(() -> new IllegalArgumentException("Latest version not found"));
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.latest.notFound"));
return downloadVersion(skill, version);
}
@ -77,20 +79,20 @@ public class SkillDownloadService {
String namespaceSlug,
String skillSlug,
String versionStr,
Long currentUserId,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug));
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
// Visibility check
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
throw new SecurityException("Access denied to skill: " + skillSlug);
throw new DomainForbiddenException("error.skill.access.denied", skillSlug);
}
SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), versionStr)
.orElseThrow(() -> new IllegalArgumentException("Version not found: " + versionStr));
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionStr));
return downloadVersion(skill, version);
}
@ -99,36 +101,39 @@ public class SkillDownloadService {
String namespaceSlug,
String skillSlug,
String tagName,
Long currentUserId,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug));
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
// Visibility check
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
throw new SecurityException("Access denied to skill: " + skillSlug);
throw new DomainForbiddenException("error.skill.access.denied", skillSlug);
}
SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName)
.orElseThrow(() -> new IllegalArgumentException("Tag not found: " + tagName));
.orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName));
if (tag.getVersionId() == null) {
throw new IllegalArgumentException("Tag does not point to a version: " + tagName);
throw new DomainBadRequestException("error.skill.tag.version.missing", tagName);
}
SkillVersion version = skillVersionRepository.findById(tag.getVersionId())
.orElseThrow(() -> new IllegalArgumentException("Version not found for tag: " + tagName));
.orElseThrow(() -> new DomainBadRequestException("error.skill.tag.version.notFound", tagName));
return downloadVersion(skill, version);
}
private DownloadResult downloadVersion(Skill skill, SkillVersion version) {
assertPublishedAccessible(skill);
assertPublishedVersion(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");
throw new DomainBadRequestException("error.skill.bundle.notFound");
}
ObjectMetadata metadata = objectStorageService.getMetadata(storageKey);
@ -144,6 +149,18 @@ public class SkillDownloadService {
private Namespace findNamespace(String slug) {
return namespaceRepository.findBySlug(slug)
.orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug));
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
}
private void assertPublishedAccessible(Skill skill) {
if (skill.getStatus() != SkillStatus.ACTIVE) {
throw new DomainBadRequestException("error.skill.status.notActive");
}
}
private void assertPublishedVersion(SkillVersion version) {
if (version.getStatus() != SkillVersionStatus.PUBLISHED) {
throw new DomainBadRequestException("error.skill.version.notPublished", version.getVersion());
}
}
}

View file

@ -5,6 +5,10 @@ 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.namespace.SlugValidator;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.*;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
@ -18,15 +22,25 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@Service
public class SkillPublishService {
public record PublishResult(
Long skillId,
String slug,
SkillVersion version
) {}
private final NamespaceRepository namespaceRepository;
private final NamespaceMemberRepository namespaceMemberRepository;
private final SkillRepository skillRepository;
@ -38,6 +52,7 @@ public class SkillPublishService {
private final PrePublishValidator prePublishValidator;
private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper objectMapper;
private final ReviewTaskRepository reviewTaskRepository;
public SkillPublishService(
NamespaceRepository namespaceRepository,
@ -50,7 +65,8 @@ public class SkillPublishService {
SkillMetadataParser skillMetadataParser,
PrePublishValidator prePublishValidator,
ApplicationEventPublisher eventPublisher,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
ReviewTaskRepository reviewTaskRepository) {
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.skillRepository = skillRepository;
@ -62,70 +78,79 @@ public class SkillPublishService {
this.prePublishValidator = prePublishValidator;
this.eventPublisher = eventPublisher;
this.objectMapper = objectMapper;
this.reviewTaskRepository = reviewTaskRepository;
}
@Transactional
public SkillVersion publishFromEntries(
public PublishResult publishFromEntries(
String namespaceSlug,
List<PackageEntry> entries,
Long publisherId,
String publisherId,
SkillVisibility visibility) {
// 1. Find namespace by slug
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
.orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + namespaceSlug));
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", namespaceSlug));
// 2. Check publisher is member
namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId)
.orElseThrow(() -> new IllegalArgumentException("Publisher is not a member of namespace: " + namespaceSlug));
.orElseThrow(() -> new DomainBadRequestException("error.skill.publish.publisher.notMember", namespaceSlug));
// 3. Validate package
ValidationResult packageValidation = skillPackageValidator.validate(entries);
if (!packageValidation.passed()) {
throw new IllegalArgumentException("Package validation failed: " + String.join(", ", packageValidation.errors()));
throw new DomainBadRequestException(
"error.skill.publish.package.invalid",
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"));
.orElseThrow(() -> new DomainBadRequestException("error.skill.publish.skillMd.notFound"));
String skillMdContent = new String(skillMd.content());
SkillMetadata metadata = skillMetadataParser.parse(skillMdContent);
if (metadata.version() == null || metadata.version().isBlank()) {
throw new DomainBadRequestException("error.skill.metadata.requiredField.missing", "version");
}
String skillSlug = SlugValidator.slugify(metadata.name());
// 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()));
throw new DomainBadRequestException(
"error.skill.publish.precheck.failed",
String.join(", ", prePublishValidation.errors()));
}
// 6. Find or create Skill record
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), metadata.name())
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseGet(() -> {
Skill newSkill = new Skill(namespace.getId(), metadata.name(), publisherId, visibility);
Skill newSkill = new Skill(namespace.getId(), skillSlug, 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());
throw new DomainBadRequestException("error.skill.version.exists", metadata.version());
}
// 8. Create SkillVersion
SkillVersion version = new SkillVersion(skill.getId(), metadata.version(), publisherId);
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setPublishedAt(LocalDateTime.now());
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
// Store metadata as JSON
try {
String metadataJson = objectMapper.writeValueAsString(metadata);
version.setParsedMetadataJson(metadataJson);
version.setManifestJson(objectMapper.writeValueAsString(buildManifest(entries)));
} catch (Exception e) {
throw new RuntimeException("Failed to serialize metadata", e);
throw new IllegalStateException("Failed to serialize metadata", e);
}
version = skillVersionRepository.save(version);
@ -168,12 +193,22 @@ public class SkillPublishService {
digest.reset();
}
} catch (Exception e) {
throw new RuntimeException("Failed to process files", e);
throw new IllegalStateException("Failed to process files", e);
}
// 10. Save SkillFile records
skillFileRepository.saveAll(skillFiles);
// 10.5 Build and upload bundle zip for download endpoints
byte[] bundleZip = buildBundle(entries);
String bundleKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId());
objectStorageService.putObject(
bundleKey,
new ByteArrayInputStream(bundleZip),
bundleZip.length,
"application/zip"
);
// 11. Update version stats
version.setFileCount(skillFiles.size());
version.setTotalSize(totalSize);
@ -189,7 +224,32 @@ public class SkillPublishService {
// 13. Publish SkillPublishedEvent
eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId));
// 14. Return version
return version;
// 14. Return published identifiers
return new PublishResult(skill.getId(), skill.getSlug(), version);
}
private List<Map<String, Object>> buildManifest(List<PackageEntry> entries) {
return entries.stream()
.map(entry -> Map.<String, Object>of(
"path", entry.path(),
"size", entry.size(),
"contentType", entry.contentType()))
.toList();
}
private byte[] buildBundle(List<PackageEntry> entries) {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) {
for (PackageEntry entry : entries) {
ZipEntry zipEntry = new ZipEntry(entry.path());
zipOutputStream.putNextEntry(zipEntry);
zipOutputStream.write(entry.content());
zipOutputStream.closeEntry();
}
zipOutputStream.finish();
return outputStream.toByteArray();
} catch (Exception e) {
throw new IllegalStateException("Failed to build bundle zip", e);
}
}
}

View file

@ -3,6 +3,8 @@ 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.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.*;
import com.iflytek.skillhub.storage.ObjectStorageService;
import org.springframework.data.domain.Page;
@ -11,8 +13,15 @@ import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@ -22,6 +31,7 @@ public class SkillQueryService {
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillFileRepository skillFileRepository;
private final SkillTagRepository skillTagRepository;
private final ObjectStorageService objectStorageService;
private final VisibilityChecker visibilityChecker;
@ -30,12 +40,14 @@ public class SkillQueryService {
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillFileRepository skillFileRepository,
SkillTagRepository skillTagRepository,
ObjectStorageService objectStorageService,
VisibilityChecker visibilityChecker) {
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillFileRepository = skillFileRepository;
this.skillTagRepository = skillTagRepository;
this.objectStorageService = objectStorageService;
this.visibilityChecker = visibilityChecker;
}
@ -53,19 +65,42 @@ public class SkillQueryService {
Long namespaceId
) {}
public record SkillVersionDetailDTO(
Long id,
String version,
String status,
String changelog,
Integer fileCount,
Long totalSize,
java.time.LocalDateTime publishedAt,
String parsedMetadataJson,
String manifestJson
) {}
public record ResolvedVersionDTO(
Long skillId,
String namespace,
String slug,
String version,
Long versionId,
String fingerprint,
Boolean matched,
String downloadUrl
) {}
public SkillDetailDTO getSkillDetail(
String namespaceSlug,
String skillSlug,
Long currentUserId,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug));
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
// Visibility check
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
throw new SecurityException("Access denied to skill: " + skillSlug);
throw new DomainForbiddenException("error.skill.access.denied", skillSlug);
}
String latestVersion = null;
@ -92,7 +127,7 @@ public class SkillQueryService {
public Page<Skill> listSkillsByNamespace(
String namespaceSlug,
Long currentUserId,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Pageable pageable) {
@ -112,38 +147,91 @@ public class SkillQueryService {
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));
public SkillVersionDetailDTO getVersionDetail(
String namespaceSlug,
String skillSlug,
String version,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
SkillVersion skillVersion = findVersion(skill, version);
assertPublishedVersion(skillVersion, version);
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
.orElseThrow(() -> new IllegalArgumentException("Version not found: " + version));
return new SkillVersionDetailDTO(
skillVersion.getId(),
skillVersion.getVersion(),
skillVersion.getStatus().name(),
skillVersion.getChangelog(),
skillVersion.getFileCount(),
skillVersion.getTotalSize(),
skillVersion.getPublishedAt(),
skillVersion.getParsedMetadataJson(),
skillVersion.getManifestJson()
);
}
public List<SkillFile> listFiles(
String namespaceSlug,
String skillSlug,
String version,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
SkillVersion skillVersion = findVersion(skill, version);
assertPublishedVersion(skillVersion, 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));
public List<SkillFile> listFilesByTag(
String namespaceSlug,
String skillSlug,
String tagName,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null);
return skillFileRepository.findByVersionId(skillVersion.getId());
}
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
.orElseThrow(() -> new IllegalArgumentException("Version not found: " + version));
public InputStream getFileContent(
String namespaceSlug,
String skillSlug,
String version,
String filePath,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
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));
SkillVersion skillVersion = findVersion(skill, version);
assertPublishedVersion(skillVersion, version);
SkillFile file = findFile(skillVersion, filePath);
return objectStorageService.getObject(file.getStorageKey());
}
public InputStream getFileContentByTag(
String namespaceSlug,
String skillSlug,
String tagName,
String filePath,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null);
SkillFile file = findFile(skillVersion, 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));
Skill skill = findSkill(namespaceSlug, skillSlug);
List<SkillVersion> publishedVersions = skillVersionRepository.findBySkillIdAndStatus(
skill.getId(), SkillVersionStatus.PUBLISHED);
@ -156,8 +244,145 @@ public class SkillQueryService {
return new PageImpl<>(pageContent, pageable, publishedVersions.size());
}
public ResolvedVersionDTO resolveVersion(
String namespaceSlug,
String skillSlug,
String version,
String tag,
String hash,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
if (version != null && !version.isBlank() && tag != null && !tag.isBlank()) {
throw new DomainBadRequestException("error.skill.resolve.versionTag.conflict");
}
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash);
String fingerprint = computeFingerprint(resolved);
Boolean matched = hash == null || hash.isBlank() ? null : Objects.equals(hash, fingerprint);
return new ResolvedVersionDTO(
skill.getId(),
namespaceSlug,
skill.getSlug(),
resolved.getVersion(),
resolved.getId(),
fingerprint,
matched,
String.format(
"/api/v1/skills/%s/%s/versions/%s/download",
encodePathSegment(namespaceSlug),
encodePathSegment(skill.getSlug()),
encodePathSegment(resolved.getVersion()))
);
}
private Namespace findNamespace(String slug) {
return namespaceRepository.findBySlug(slug)
.orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug));
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
}
private Skill findSkill(String namespaceSlug, String skillSlug) {
Namespace namespace = findNamespace(namespaceSlug);
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
}
private SkillVersion findVersion(Skill skill, String version) {
return skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
}
private SkillFile findFile(SkillVersion skillVersion, String filePath) {
return skillFileRepository.findByVersionId(skillVersion.getId()).stream()
.filter(f -> f.getFilePath().equals(filePath))
.findFirst()
.orElseThrow(() -> new DomainBadRequestException("error.skill.file.notFound", filePath));
}
private SkillVersion resolveVersionEntity(Skill skill, String version, String tag, String hash) {
if (version != null && !version.isBlank()) {
SkillVersion exactVersion = findVersion(skill, version);
assertPublishedVersion(exactVersion, version);
return exactVersion;
}
if (tag != null && !tag.isBlank()) {
if ("latest".equalsIgnoreCase(tag)) {
return resolveLatestVersion(skill);
}
SkillTag skillTag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tag)
.orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tag));
if (skillTag.getVersionId() == null) {
throw new DomainBadRequestException("error.skill.tag.version.missing", tag);
}
SkillVersion taggedVersion = skillVersionRepository.findById(skillTag.getVersionId())
.orElseThrow(() -> new DomainBadRequestException("error.skill.tag.version.notFound", tag));
assertPublishedVersion(taggedVersion, taggedVersion.getVersion());
return taggedVersion;
}
List<SkillVersion> publishedVersions = skillVersionRepository.findBySkillIdAndStatus(
skill.getId(), SkillVersionStatus.PUBLISHED);
if (publishedVersions.isEmpty()) {
throw new DomainBadRequestException("error.skill.version.latest.unavailable", skill.getSlug());
}
if (hash != null && !hash.isBlank()) {
Optional<SkillVersion> matchedVersion = publishedVersions.stream()
.filter(candidate -> Objects.equals(hash, computeFingerprint(candidate)))
.findFirst();
if (matchedVersion.isPresent()) {
return matchedVersion.get();
}
}
return resolveLatestVersion(skill);
}
private SkillVersion resolveLatestVersion(Skill skill) {
if (skill.getLatestVersionId() == null) {
throw new DomainBadRequestException("error.skill.version.latest.unavailable", skill.getSlug());
}
SkillVersion latestVersion = skillVersionRepository.findById(skill.getLatestVersionId())
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.latest.notFound"));
assertPublishedVersion(latestVersion, latestVersion.getVersion());
return latestVersion;
}
private String computeFingerprint(SkillVersion version) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId()).stream()
.sorted(Comparator.comparing(SkillFile::getFilePath))
.toList();
for (SkillFile file : files) {
String line = file.getFilePath() + ":" + file.getSha256() + "\n";
digest.update(line.getBytes(StandardCharsets.UTF_8));
}
return "sha256:" + HexFormat.of().formatHex(digest.digest());
} catch (Exception e) {
throw new IllegalStateException("Failed to compute version fingerprint", e);
}
}
private String encodePathSegment(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
}
private void assertPublishedAccessible(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
if (skill.getStatus() != SkillStatus.ACTIVE) {
throw new DomainBadRequestException("error.skill.status.notActive");
}
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug());
}
}
private void assertPublishedVersion(SkillVersion version, String versionStr) {
if (version.getStatus() != SkillVersionStatus.PUBLISHED) {
throw new DomainBadRequestException("error.skill.version.notPublished", versionStr);
}
}
}

View file

@ -1,7 +1,11 @@
package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -14,16 +18,19 @@ public class SkillTagService {
private static final String RESERVED_TAG_LATEST = "latest";
private final NamespaceRepository namespaceRepository;
private final NamespaceMemberRepository namespaceMemberRepository;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillTagRepository skillTagRepository;
public SkillTagService(
NamespaceRepository namespaceRepository,
NamespaceMemberRepository namespaceMemberRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillTagRepository skillTagRepository) {
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillTagRepository = skillTagRepository;
@ -32,9 +39,13 @@ public class SkillTagService {
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));
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
return skillTagRepository.findBySkillId(skill.getId());
List<SkillTag> tags = new java.util.ArrayList<>(skillTagRepository.findBySkillId(skill.getId()));
if (skill.getLatestVersionId() != null) {
tags.add(new SkillTag(skill.getId(), RESERVED_TAG_LATEST, skill.getLatestVersionId(), skill.getOwnerId()));
}
return tags;
}
@Transactional
@ -43,24 +54,25 @@ public class SkillTagService {
String skillSlug,
String tagName,
String targetVersion,
Long operatorId) {
String operatorId) {
// Reject "latest" tag
if (RESERVED_TAG_LATEST.equalsIgnoreCase(tagName)) {
throw new IllegalArgumentException("Tag name 'latest' is reserved");
throw new DomainBadRequestException("error.skill.tag.latest.reserved");
}
Namespace namespace = findNamespace(namespaceSlug);
assertAdminOrOwner(namespace.getId(), operatorId);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug));
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
// Find target version
SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), targetVersion)
.orElseThrow(() -> new IllegalArgumentException("Version not found: " + targetVersion));
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", targetVersion));
// Target must be PUBLISHED
if (version.getStatus() != SkillVersionStatus.PUBLISHED) {
throw new IllegalArgumentException("Target version must be PUBLISHED");
throw new DomainBadRequestException("error.skill.tag.targetVersion.notPublished");
}
// Check if tag exists
@ -78,24 +90,34 @@ public class SkillTagService {
}
@Transactional
public void deleteTag(String namespaceSlug, String skillSlug, String tagName, Long operatorId) {
public void deleteTag(String namespaceSlug, String skillSlug, String tagName, String operatorId) {
// Reject "latest" tag
if (RESERVED_TAG_LATEST.equalsIgnoreCase(tagName)) {
throw new IllegalArgumentException("Tag name 'latest' is reserved and cannot be deleted");
throw new DomainBadRequestException("error.skill.tag.latest.delete");
}
Namespace namespace = findNamespace(namespaceSlug);
assertAdminOrOwner(namespace.getId(), operatorId);
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug));
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName)
.orElseThrow(() -> new IllegalArgumentException("Tag not found: " + tagName));
.orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName));
skillTagRepository.delete(tag);
}
private Namespace findNamespace(String slug) {
return namespaceRepository.findBySlug(slug)
.orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug));
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
}
private void assertAdminOrOwner(Long namespaceId, String operatorId) {
NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorId)
.map(member -> member.getRole())
.orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required"));
if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) {
throw new DomainForbiddenException("error.namespace.admin.required");
}
}
}

View file

@ -1,5 +1,8 @@
package com.iflytek.skillhub.domain.skill.validation;
import org.springframework.stereotype.Component;
@Component
public class NoOpPrePublishValidator implements PrePublishValidator {
@Override

View file

@ -10,7 +10,7 @@ public interface PrePublishValidator {
record SkillPackageContext(
List<PackageEntry> entries,
SkillMetadata metadata,
Long publisherId,
String publisherId,
Long namespaceId
) {}
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.skill.validation;
import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
import java.util.ArrayList;
@ -42,8 +43,11 @@ public class SkillPackageValidator {
try {
String content = new String(skillMd.content());
metadataParser.parse(content);
} catch (IllegalArgumentException e) {
errors.add("Invalid SKILL.md frontmatter: " + e.getMessage());
} catch (LocalizedDomainException e) {
String detail = e.messageArgs().length == 0
? e.messageCode()
: e.messageCode() + " " + java.util.Arrays.toString(e.messageArgs());
errors.add("Invalid SKILL.md frontmatter: " + detail);
}
// 3. Check file count

View file

@ -7,8 +7,8 @@ import java.time.LocalDateTime;
@Table(name = "user_account")
public class UserAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 128)
private String id;
@Column(name = "display_name", nullable = false, length = 128)
private String displayName;
@ -24,7 +24,7 @@ public class UserAccount {
private UserStatus status = UserStatus.ACTIVE;
@Column(name = "merged_to_user_id")
private Long mergedToUserId;
private String mergedToUserId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@ -34,7 +34,8 @@ public class UserAccount {
protected UserAccount() {}
public UserAccount(String displayName, String email, String avatarUrl) {
public UserAccount(String id, String displayName, String email, String avatarUrl) {
this.id = id;
this.displayName = displayName;
this.email = email;
this.avatarUrl = avatarUrl;
@ -52,7 +53,7 @@ public class UserAccount {
this.updatedAt = LocalDateTime.now();
}
public Long getId() { return id; }
public String getId() { return id; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getEmail() { return email; }
@ -61,8 +62,8 @@ public class UserAccount {
public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; }
public UserStatus getStatus() { return status; }
public void setStatus(UserStatus status) { this.status = status; }
public Long getMergedToUserId() { return mergedToUserId; }
public void setMergedToUserId(Long mergedToUserId) { this.mergedToUserId = mergedToUserId; }
public String getMergedToUserId() { return mergedToUserId; }
public void setMergedToUserId(String mergedToUserId) { this.mergedToUserId = mergedToUserId; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public boolean isActive() { return this.status == UserStatus.ACTIVE; }

View file

@ -3,6 +3,6 @@ package com.iflytek.skillhub.domain.user;
import java.util.Optional;
public interface UserAccountRepository {
Optional<UserAccount> findById(Long id);
Optional<UserAccount> findById(String id);
UserAccount save(UserAccount user);
}

View file

@ -1,16 +1,12 @@
package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@ -22,6 +18,8 @@ class NamespaceMemberServiceTest {
@Mock
private NamespaceMemberRepository namespaceMemberRepository;
@Mock
private NamespaceService namespaceService;
@InjectMocks
private NamespaceMemberService namespaceMemberService;
@ -29,7 +27,7 @@ class NamespaceMemberServiceTest {
@Test
void addMember_shouldAddMemberSuccessfully() {
Long namespaceId = 1L;
Long userId = 2L;
String userId = "user-2";
NamespaceRole role = NamespaceRole.MEMBER;
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId))
@ -37,7 +35,7 @@ class NamespaceMemberServiceTest {
when(namespaceMemberRepository.save(any(NamespaceMember.class)))
.thenReturn(new NamespaceMember(namespaceId, userId, role));
NamespaceMember result = namespaceMemberService.addMember(namespaceId, userId, role);
NamespaceMember result = namespaceMemberService.addMember(namespaceId, userId, role, "user-99");
assertNotNull(result);
verify(namespaceMemberRepository).save(any(NamespaceMember.class));
@ -45,52 +43,60 @@ class NamespaceMemberServiceTest {
@Test
void addMember_shouldThrowExceptionForOwnerRole() {
assertThrows(IllegalArgumentException.class, () ->
namespaceMemberService.addMember(1L, 2L, NamespaceRole.OWNER));
assertThrows(DomainBadRequestException.class, () ->
namespaceMemberService.addMember(1L, "user-2", NamespaceRole.OWNER, "user-99"));
}
@Test
void addMember_shouldRequireAdminOrOwner() {
doThrow(new DomainForbiddenException("error.namespace.admin.required")).when(namespaceService).assertAdminOrOwner(1L, "user-99");
assertThrows(DomainForbiddenException.class, () ->
namespaceMemberService.addMember(1L, "user-2", NamespaceRole.MEMBER, "user-99"));
}
@Test
void addMember_shouldThrowExceptionWhenMemberExists() {
Long namespaceId = 1L;
Long userId = 2L;
String userId = "user-2";
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId))
.thenReturn(Optional.of(new NamespaceMember()));
assertThrows(IllegalArgumentException.class, () ->
namespaceMemberService.addMember(namespaceId, userId, NamespaceRole.MEMBER));
assertThrows(DomainBadRequestException.class, () ->
namespaceMemberService.addMember(namespaceId, userId, NamespaceRole.MEMBER, "user-99"));
}
@Test
void removeMember_shouldThrowExceptionForOwner() {
Long namespaceId = 1L;
Long userId = 2L;
String userId = "user-2";
NamespaceMember ownerMember = new NamespaceMember(namespaceId, userId, NamespaceRole.OWNER);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId))
.thenReturn(Optional.of(ownerMember));
assertThrows(IllegalArgumentException.class, () ->
namespaceMemberService.removeMember(namespaceId, userId));
assertThrows(DomainBadRequestException.class, () ->
namespaceMemberService.removeMember(namespaceId, userId, "user-99"));
}
@Test
void removeMember_shouldThrowExceptionWhenMemberNotFound() {
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, 2L))
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2"))
.thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () ->
namespaceMemberService.removeMember(1L, 2L));
assertThrows(DomainBadRequestException.class, () ->
namespaceMemberService.removeMember(1L, "user-2", "user-99"));
}
@Test
void updateMemberRole_shouldUpdateRoleSuccessfully() {
Long namespaceId = 1L;
Long userId = 2L;
String userId = "user-2";
NamespaceMember member = new NamespaceMember(namespaceId, userId, NamespaceRole.MEMBER);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId))
.thenReturn(Optional.of(member));
when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenReturn(member);
NamespaceMember result = namespaceMemberService.updateMemberRole(namespaceId, userId, NamespaceRole.ADMIN);
NamespaceMember result = namespaceMemberService.updateMemberRole(namespaceId, userId, NamespaceRole.ADMIN, "user-99");
assertNotNull(result);
verify(namespaceMemberRepository).save(member);
@ -99,26 +105,26 @@ class NamespaceMemberServiceTest {
@Test
void updateMemberRole_shouldThrowExceptionForOwnerRole() {
Long namespaceId = 1L;
Long userId = 2L;
String userId = "user-2";
assertThrows(IllegalArgumentException.class, () ->
namespaceMemberService.updateMemberRole(namespaceId, userId, NamespaceRole.OWNER));
assertThrows(DomainBadRequestException.class, () ->
namespaceMemberService.updateMemberRole(namespaceId, userId, NamespaceRole.OWNER, "user-99"));
}
@Test
void updateMemberRole_shouldThrowExceptionWhenMemberNotFound() {
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, 2L))
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2"))
.thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () ->
namespaceMemberService.updateMemberRole(1L, 2L, NamespaceRole.ADMIN));
assertThrows(DomainBadRequestException.class, () ->
namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.ADMIN, "user-99"));
}
@Test
void transferOwnership_shouldTransferOwnershipSuccessfully() {
Long namespaceId = 1L;
Long currentOwnerId = 2L;
Long newOwnerId = 3L;
String currentOwnerId = "user-2";
String newOwnerId = "user-3";
NamespaceMember currentOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.OWNER);
NamespaceMember newOwner = new NamespaceMember(namespaceId, newOwnerId, NamespaceRole.ADMIN);
@ -137,30 +143,30 @@ class NamespaceMemberServiceTest {
@Test
void transferOwnership_shouldThrowExceptionWhenCurrentOwnerNotFound() {
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, 2L))
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2"))
.thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () ->
namespaceMemberService.transferOwnership(1L, 2L, 3L));
assertThrows(DomainBadRequestException.class, () ->
namespaceMemberService.transferOwnership(1L, "user-2", "user-3"));
}
@Test
void transferOwnership_shouldThrowExceptionWhenCurrentUserIsNotOwner() {
Long namespaceId = 1L;
Long currentOwnerId = 2L;
String currentOwnerId = "user-2";
NamespaceMember notOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.ADMIN);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId))
.thenReturn(Optional.of(notOwner));
assertThrows(IllegalArgumentException.class, () ->
namespaceMemberService.transferOwnership(namespaceId, currentOwnerId, 3L));
assertThrows(DomainBadRequestException.class, () ->
namespaceMemberService.transferOwnership(namespaceId, currentOwnerId, "user-3"));
}
@Test
void transferOwnership_shouldThrowExceptionWhenNewOwnerNotFound() {
Long namespaceId = 1L;
Long currentOwnerId = 2L;
Long newOwnerId = 3L;
String currentOwnerId = "user-2";
String newOwnerId = "user-3";
NamespaceMember currentOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.OWNER);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId))
@ -168,14 +174,14 @@ class NamespaceMemberServiceTest {
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, newOwnerId))
.thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () ->
assertThrows(DomainBadRequestException.class, () ->
namespaceMemberService.transferOwnership(namespaceId, currentOwnerId, newOwnerId));
}
@Test
void getMemberRole_shouldReturnRole() {
Long namespaceId = 1L;
Long userId = 2L;
String userId = "user-2";
NamespaceMember member = new NamespaceMember(namespaceId, userId, NamespaceRole.ADMIN);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId))
.thenReturn(Optional.of(member));
@ -188,10 +194,10 @@ class NamespaceMemberServiceTest {
@Test
void getMemberRole_shouldReturnEmptyWhenMemberNotFound() {
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, 2L))
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2"))
.thenReturn(Optional.empty());
Optional<NamespaceRole> result = namespaceMemberService.getMemberRole(1L, 2L);
Optional<NamespaceRole> result = namespaceMemberService.getMemberRole(1L, "user-2");
assertFalse(result.isPresent());
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.domain.namespace;
import org.junit.jupiter.api.BeforeEach;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -30,7 +31,7 @@ class NamespaceServiceTest {
String slug = "test-namespace";
String displayName = "Test Namespace";
String description = "Test description";
Long creatorUserId = 1L;
String creatorUserId = "user-1";
Namespace savedNamespace = new Namespace(slug, displayName, creatorUserId);
when(namespaceRepository.findBySlug(slug)).thenReturn(Optional.empty());
@ -51,24 +52,33 @@ class NamespaceServiceTest {
String slug = "existing-slug";
when(namespaceRepository.findBySlug(slug)).thenReturn(Optional.of(new Namespace()));
assertThrows(IllegalArgumentException.class, () ->
namespaceService.createNamespace(slug, "Name", "Desc", 1L));
assertThrows(DomainBadRequestException.class, () ->
namespaceService.createNamespace(slug, "Name", "Desc", "user-1"));
}
@Test
void createNamespace_shouldThrowExceptionForInvalidSlug() {
assertThrows(IllegalArgumentException.class, () ->
namespaceService.createNamespace("INVALID", "Name", "Desc", 1L));
assertThrows(DomainBadRequestException.class, () ->
namespaceService.createNamespace("INVALID", "Name", "Desc", "user-1"));
}
@Test
void updateNamespace_shouldUpdateFields() {
Long namespaceId = 1L;
Namespace namespace = new Namespace("slug", "Old Name", 1L);
String operatorUserId = "user-1";
Namespace namespace = new Namespace("slug", "Old Name", "user-1");
when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId))
.thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER)));
when(namespaceRepository.save(any(Namespace.class))).thenReturn(namespace);
Namespace result = namespaceService.updateNamespace(namespaceId, "New Name", "New Desc", "http://avatar.url");
Namespace result = namespaceService.updateNamespace(
namespaceId,
"New Name",
"New Desc",
"http://avatar.url",
operatorUserId
);
assertNotNull(result);
verify(namespaceRepository).save(namespace);
@ -78,14 +88,27 @@ class NamespaceServiceTest {
void updateNamespace_shouldThrowExceptionWhenNotFound() {
when(namespaceRepository.findById(1L)).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () ->
namespaceService.updateNamespace(1L, "Name", "Desc", null));
assertThrows(DomainBadRequestException.class, () ->
namespaceService.updateNamespace(1L, "Name", "Desc", null, "user-1"));
}
@Test
void updateNamespace_shouldThrowExceptionWhenOperatorLacksPrivilege() {
Long namespaceId = 1L;
String operatorUserId = "user-2";
Namespace namespace = new Namespace("slug", "Old Name", "user-1");
when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId))
.thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.MEMBER)));
assertThrows(DomainForbiddenException.class, () ->
namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId));
}
@Test
void getNamespaceBySlug_shouldReturnNamespace() {
String slug = "test-slug";
Namespace namespace = new Namespace(slug, "Name", 1L);
Namespace namespace = new Namespace(slug, "Name", "user-1");
when(namespaceRepository.findBySlug(slug)).thenReturn(Optional.of(namespace));
Namespace result = namespaceService.getNamespaceBySlug(slug);
@ -98,7 +121,7 @@ class NamespaceServiceTest {
void getNamespaceBySlug_shouldThrowExceptionWhenNotFound() {
when(namespaceRepository.findBySlug("nonexistent")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () ->
assertThrows(DomainBadRequestException.class, () ->
namespaceService.getNamespaceBySlug("nonexistent"));
}
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@ -13,45 +14,45 @@ class SlugValidatorTest {
}
@Test
void shouldRejectTooShort() {
Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("a"));
assertTrue(ex.getMessage().contains("length"));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("a"));
assertEquals("error.slug.length", ex.messageCode());
}
@Test
void shouldRejectTooLong() {
String longSlug = "a".repeat(65);
Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate(longSlug));
assertTrue(ex.getMessage().contains("length"));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate(longSlug));
assertEquals("error.slug.length", ex.messageCode());
}
@Test
void shouldRejectUpperCase() {
Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("MyNamespace"));
assertTrue(ex.getMessage().contains("lowercase"));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("MyNamespace"));
assertEquals("error.slug.pattern", ex.messageCode());
}
@Test
void shouldRejectStartingWithHyphen() {
Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("-namespace"));
assertTrue(ex.getMessage().contains("alphanumeric"));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("-namespace"));
assertEquals("error.slug.pattern", ex.messageCode());
}
@Test
void shouldRejectEndingWithHyphen() {
Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("namespace-"));
assertTrue(ex.getMessage().contains("alphanumeric"));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("namespace-"));
assertEquals("error.slug.pattern", ex.messageCode());
}
@Test
void shouldRejectDoubleHyphen() {
Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("my--namespace"));
assertTrue(ex.getMessage().contains("consecutive"));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("my--namespace"));
assertEquals("error.slug.doubleHyphen", ex.messageCode());
}
@Test
void shouldRejectReservedWords() {
assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("admin"));
assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("api"));
assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("global"));
assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("system"));
assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("admin"));
assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("api"));
assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("global"));
assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("system"));
}
@Test
void shouldRejectSpecialCharacters() {
Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("my_namespace"));
assertTrue(ex.getMessage().contains("lowercase") || ex.getMessage().contains("alphanumeric"));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> SlugValidator.validate("my_namespace"));
assertEquals("error.slug.pattern", ex.messageCode());
}
}

View file

@ -40,8 +40,8 @@ class PromotionServiceTest {
private static final Long SOURCE_SKILL_ID = 10L;
private static final Long SOURCE_VERSION_ID = 20L;
private static final Long TARGET_NAMESPACE_ID = 30L;
private static final Long USER_ID = 100L;
private static final Long REVIEWER_ID = 200L;
private static final String USER_ID = "user-100";
private static final String REVIEWER_ID = "user-200";
private static final Long PROMOTION_ID = 1L;
private static final Long NEW_SKILL_ID = 50L;
private static final Long NEW_VERSION_ID = 60L;
@ -84,14 +84,14 @@ class PromotionServiceTest {
}
private Namespace createGlobalNamespace() {
Namespace ns = new Namespace("global", "Global", 1L);
Namespace ns = new Namespace("global", "Global", "user-1");
setField(ns, "id", TARGET_NAMESPACE_ID);
ns.setType(NamespaceType.GLOBAL);
return ns;
}
private Namespace createTeamNamespace() {
Namespace ns = new Namespace("team-a", "Team A", 1L);
Namespace ns = new Namespace("team-a", "Team A", "user-1");
setField(ns, "id", TARGET_NAMESPACE_ID);
// default type is TEAM
return ns;

View file

@ -17,7 +17,7 @@ class ReviewPermissionCheckerTest {
@Test
void cannotReviewOwnSubmission() {
Long userId = 1L;
String userId = "user-1";
ReviewTask task = new ReviewTask(1L, 10L, userId);
assertFalse(checker.canReview(task, userId,
NamespaceType.TEAM, Map.of(), Set.of()));
@ -25,56 +25,56 @@ class ReviewPermissionCheckerTest {
@Test
void teamAdminCanReviewTeamSkill() {
ReviewTask task = new ReviewTask(1L, 10L, 2L);
assertTrue(checker.canReview(task, 1L,
ReviewTask task = new ReviewTask(1L, 10L, "user-2");
assertTrue(checker.canReview(task, "user-1",
NamespaceType.TEAM,
Map.of(10L, NamespaceRole.ADMIN), Set.of()));
}
@Test
void teamOwnerCanReviewTeamSkill() {
ReviewTask task = new ReviewTask(1L, 10L, 2L);
assertTrue(checker.canReview(task, 1L,
ReviewTask task = new ReviewTask(1L, 10L, "user-2");
assertTrue(checker.canReview(task, "user-1",
NamespaceType.TEAM,
Map.of(10L, NamespaceRole.OWNER), Set.of()));
}
@Test
void teamMemberCannotReviewTeamSkill() {
ReviewTask task = new ReviewTask(1L, 10L, 2L);
assertFalse(checker.canReview(task, 1L,
ReviewTask task = new ReviewTask(1L, 10L, "user-2");
assertFalse(checker.canReview(task, "user-1",
NamespaceType.TEAM,
Map.of(10L, NamespaceRole.MEMBER), Set.of()));
}
@Test
void skillAdminCanReviewGlobalSkill() {
ReviewTask task = new ReviewTask(1L, 1L, 2L);
assertTrue(checker.canReview(task, 1L,
ReviewTask task = new ReviewTask(1L, 1L, "user-2");
assertTrue(checker.canReview(task, "user-1",
NamespaceType.GLOBAL,
Map.of(), Set.of("SKILL_ADMIN")));
}
@Test
void superAdminCanReviewGlobalSkill() {
ReviewTask task = new ReviewTask(1L, 1L, 2L);
assertTrue(checker.canReview(task, 1L,
ReviewTask task = new ReviewTask(1L, 1L, "user-2");
assertTrue(checker.canReview(task, "user-1",
NamespaceType.GLOBAL,
Map.of(), Set.of("SUPER_ADMIN")));
}
@Test
void skillAdminCannotReviewTeamSkill() {
ReviewTask task = new ReviewTask(1L, 10L, 2L);
assertFalse(checker.canReview(task, 1L,
ReviewTask task = new ReviewTask(1L, 10L, "user-2");
assertFalse(checker.canReview(task, "user-1",
NamespaceType.TEAM,
Map.of(), Set.of("SKILL_ADMIN")));
}
@Test
void nonAdminCannotReviewGlobalSkill() {
ReviewTask task = new ReviewTask(1L, 1L, 2L);
assertFalse(checker.canReview(task, 1L,
ReviewTask task = new ReviewTask(1L, 1L, "user-2");
assertFalse(checker.canReview(task, "user-1",
NamespaceType.GLOBAL,
Map.of(), Set.of()));
}
@ -83,28 +83,28 @@ class ReviewPermissionCheckerTest {
@Test
void skillAdminCanReviewPromotion() {
PromotionRequest req = new PromotionRequest(1L, 1L, 1L, 2L);
assertTrue(checker.canReviewPromotion(req, 1L,
PromotionRequest req = new PromotionRequest(1L, 1L, 1L, "user-2");
assertTrue(checker.canReviewPromotion(req, "user-1",
Set.of("SKILL_ADMIN")));
}
@Test
void superAdminCanReviewPromotion() {
PromotionRequest req = new PromotionRequest(1L, 1L, 1L, 2L);
assertTrue(checker.canReviewPromotion(req, 1L,
PromotionRequest req = new PromotionRequest(1L, 1L, 1L, "user-2");
assertTrue(checker.canReviewPromotion(req, "user-1",
Set.of("SUPER_ADMIN")));
}
@Test
void regularUserCannotReviewPromotion() {
PromotionRequest req = new PromotionRequest(1L, 1L, 1L, 2L);
assertFalse(checker.canReviewPromotion(req, 1L,
PromotionRequest req = new PromotionRequest(1L, 1L, 1L, "user-2");
assertFalse(checker.canReviewPromotion(req, "user-1",
Set.of()));
}
@Test
void cannotReviewOwnPromotion() {
Long userId = 2L;
String userId = "user-2";
PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId);
assertFalse(checker.canReviewPromotion(req, userId,
Set.of("SKILL_ADMIN")));

View file

@ -46,8 +46,8 @@ class ReviewServiceTest {
private static final Long SKILL_VERSION_ID = 10L;
private static final Long NAMESPACE_ID = 20L;
private static final Long USER_ID = 100L;
private static final Long REVIEWER_ID = 200L;
private static final String USER_ID = "user-100";
private static final String REVIEWER_ID = "user-200";
private static final Long REVIEW_TASK_ID = 1L;
private static final Long SKILL_ID = 30L;
@ -77,7 +77,7 @@ class ReviewServiceTest {
}
private Namespace createTeamNamespace() {
Namespace ns = new Namespace("team-a", "Team A", 1L);
Namespace ns = new Namespace("team-a", "Team A", "user-1");
setField(ns, "id", NAMESPACE_ID);
return ns;
}
@ -347,7 +347,7 @@ class ReviewServiceTest {
when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING))
.thenReturn(Optional.of(task));
Long otherUserId = 999L;
String otherUserId = "user-999";
assertThrows(DomainForbiddenException.class,
() -> reviewService.withdrawReview(SKILL_VERSION_ID, otherUserId));
}

View file

@ -16,10 +16,10 @@ class VisibilityCheckerTest {
private Skill privateSkill;
private static final Long NAMESPACE_ID = 1L;
private static final Long OWNER_ID = 100L;
private static final Long OTHER_USER_ID = 200L;
private static final Long ADMIN_USER_ID = 300L;
private static final Long NAMESPACE_OWNER_ID = 400L;
private static final String OWNER_ID = "user-100";
private static final String OTHER_USER_ID = "user-200";
private static final String ADMIN_USER_ID = "user-300";
private static final String NAMESPACE_OWNER_ID = "user-400";
@BeforeEach
void setUp() {

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.skill.metadata;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.junit.jupiter.api.Test;
import java.util.Map;
@ -62,11 +63,11 @@ class SkillMetadataParserTest {
void testThrowsWhenNoFrontmatter() {
String content = "# Just a markdown file without frontmatter";
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
DomainBadRequestException exception = assertThrows(
DomainBadRequestException.class,
() -> parser.parse(content)
);
assertTrue(exception.getMessage().contains("Missing frontmatter"));
assertEquals("error.skill.metadata.frontmatter.missingStart", exception.messageCode());
}
@Test
@ -79,11 +80,12 @@ class SkillMetadataParserTest {
Body
""";
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
DomainBadRequestException exception = assertThrows(
DomainBadRequestException.class,
() -> parser.parse(content)
);
assertTrue(exception.getMessage().contains("name"));
assertEquals("error.skill.metadata.requiredField.missing", exception.messageCode());
assertEquals("name", exception.messageArgs()[0]);
}
@Test
@ -96,15 +98,16 @@ class SkillMetadataParserTest {
Body
""";
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
DomainBadRequestException exception = assertThrows(
DomainBadRequestException.class,
() -> parser.parse(content)
);
assertTrue(exception.getMessage().contains("description"));
assertEquals("error.skill.metadata.requiredField.missing", exception.messageCode());
assertEquals("description", exception.messageArgs()[0]);
}
@Test
void testThrowsWhenMissingVersion() {
void testAllowsMissingVersion() {
String content = """
---
name: test-skill
@ -113,11 +116,11 @@ class SkillMetadataParserTest {
Body
""";
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> parser.parse(content)
);
assertTrue(exception.getMessage().contains("version"));
SkillMetadata metadata = parser.parse(content);
assertEquals("test-skill", metadata.name());
assertEquals("Test description", metadata.description());
assertNull(metadata.version());
}
@Test
@ -131,11 +134,11 @@ class SkillMetadataParserTest {
Body
""";
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
DomainBadRequestException exception = assertThrows(
DomainBadRequestException.class,
() -> parser.parse(content)
);
assertTrue(exception.getMessage().contains("Invalid YAML"));
assertEquals("error.skill.metadata.yaml.invalid", exception.messageCode());
}
@Test
@ -147,10 +150,10 @@ class SkillMetadataParserTest {
version: 1.0.0
""";
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
DomainBadRequestException exception = assertThrows(
DomainBadRequestException.class,
() -> parser.parse(content)
);
assertTrue(exception.getMessage().contains("Missing frontmatter"));
assertEquals("error.skill.metadata.frontmatter.missingEnd", exception.messageCode());
}
}

View file

@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.event.SkillDownloadedEvent;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.*;
import com.iflytek.skillhub.storage.ObjectMetadata;
import com.iflytek.skillhub.storage.ObjectStorageService;
@ -63,17 +64,19 @@ class SkillDownloadServiceTest {
// Arrange
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
Long userId = 100L;
String userId = "user-100";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
skill.setLatestVersionId(10L);
SkillVersion version = new SkillVersion(1L, "1.0.0", userId);
setId(version, 10L);
version.setStatus(SkillVersionStatus.PUBLISHED);
String storageKey = "packages/1/10/bundle.zip";
InputStream content = new ByteArrayInputStream("test".getBytes());
ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
@ -102,16 +105,18 @@ class SkillDownloadServiceTest {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String tagName = "stable";
Long userId = 100L;
String userId = "user-100";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
SkillTag tag = new SkillTag(1L, tagName, 10L, userId);
SkillVersion version = new SkillVersion(1L, "1.0.0", userId);
setId(version, 10L);
version.setStatus(SkillVersionStatus.PUBLISHED);
String storageKey = "packages/1/10/bundle.zip";
InputStream content = new ByteArrayInputStream("test".getBytes());
ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
@ -134,6 +139,32 @@ class SkillDownloadServiceTest {
verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class));
}
@Test
void testDownloadVersion_ShouldRejectDraftVersion() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String versionStr = "1.0.0";
String userId = "user-100";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
SkillVersion version = new SkillVersion(1L, versionStr, userId);
setId(version, 10L);
version.setStatus(SkillVersionStatus.DRAFT);
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.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version));
assertThrows(DomainBadRequestException.class, () ->
service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles));
}
private void setId(Object entity, Long id) throws Exception {
Field idField = entity.getClass().getDeclaredField("id");
idField.setAccessible(true);

View file

@ -6,6 +6,8 @@ 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.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.*;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
@ -54,6 +56,8 @@ class SkillPublishServiceTest {
private PrePublishValidator prePublishValidator;
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private ReviewTaskRepository reviewTaskRepository;
private SkillPublishService service;
private ObjectMapper objectMapper;
@ -72,7 +76,8 @@ class SkillPublishServiceTest {
skillMetadataParser,
prePublishValidator,
eventPublisher,
objectMapper
objectMapper,
reviewTaskRepository
);
}
@ -80,14 +85,14 @@ class SkillPublishServiceTest {
void testPublishFromEntries_Success() throws Exception {
// Arrange
String namespaceSlug = "test-ns";
Long publisherId = 100L;
String publisherId = "user-100";
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);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
@ -108,10 +113,18 @@ class SkillPublishServiceTest {
when(skillRepository.save(any())).thenReturn(skill);
// Act
SkillVersion result = service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC);
SkillPublishService.PublishResult result = service.publishFromEntries(
namespaceSlug,
entries,
publisherId,
SkillVisibility.PUBLIC
);
// Assert
assertNotNull(result);
assertEquals(1L, result.skillId());
assertEquals("test-skill", result.slug());
assertEquals("1.0.0", result.version().getVersion());
verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
verify(skillFileRepository).saveAll(anyList());
verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString());
@ -124,8 +137,8 @@ class SkillPublishServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.empty());
// Act & Assert
assertThrows(IllegalArgumentException.class, () ->
service.publishFromEntries(namespaceSlug, List.of(), 100L, SkillVisibility.PUBLIC)
assertThrows(DomainBadRequestException.class, () ->
service.publishFromEntries(namespaceSlug, List.of(), "user-100", SkillVisibility.PUBLIC)
);
}
@ -133,15 +146,15 @@ class SkillPublishServiceTest {
void testPublishFromEntries_NotAMember() throws Exception {
// Arrange
String namespaceSlug = "test-ns";
Long publisherId = 100L;
Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
String publisherId = "user-100";
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
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, () ->
assertThrows(DomainBadRequestException.class, () ->
service.publishFromEntries(namespaceSlug, List.of(), publisherId, SkillVisibility.PUBLIC)
);
}

View file

@ -3,6 +3,8 @@ 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.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.*;
import com.iflytek.skillhub.storage.ObjectStorageService;
import org.junit.jupiter.api.BeforeEach;
@ -37,6 +39,8 @@ class SkillQueryServiceTest {
@Mock
private SkillFileRepository skillFileRepository;
@Mock
private SkillTagRepository skillTagRepository;
@Mock
private ObjectStorageService objectStorageService;
@Mock
private VisibilityChecker visibilityChecker;
@ -50,6 +54,7 @@ class SkillQueryServiceTest {
skillRepository,
skillVersionRepository,
skillFileRepository,
skillTagRepository,
objectStorageService,
visibilityChecker
);
@ -60,10 +65,10 @@ class SkillQueryServiceTest {
// Arrange
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
Long userId = 100L;
String userId = "user-100";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
setId(skill, 1L);
@ -94,12 +99,12 @@ class SkillQueryServiceTest {
// Arrange
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
Long userId = 100L;
String userId = "user-100";
Map<Long, NamespaceRole> userNsRoles = Map.of();
Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, 200L, SkillVisibility.PRIVATE);
Skill skill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PRIVATE);
setId(skill, 1L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
@ -107,7 +112,7 @@ class SkillQueryServiceTest {
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false);
// Act & Assert
assertThrows(SecurityException.class, () ->
assertThrows(DomainForbiddenException.class, () ->
service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles)
);
}
@ -116,11 +121,11 @@ class SkillQueryServiceTest {
void testListSkillsByNamespace() throws Exception {
// Arrange
String namespaceSlug = "test-ns";
Long userId = 100L;
String userId = "user-100";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Pageable pageable = PageRequest.of(0, 10);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill skill1 = new Skill(1L, "skill1", userId, SkillVisibility.PUBLIC);
setId(skill1, 1L);
@ -147,27 +152,167 @@ class SkillQueryServiceTest {
String skillSlug = "test-skill";
String version = "1.0.0";
Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, 100L, SkillVisibility.PUBLIC);
Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC);
setId(skill, 1L);
SkillVersion skillVersion = new SkillVersion(1L, version, 100L);
skill.setStatus(SkillStatus.ACTIVE);
SkillVersion skillVersion = new SkillVersion(1L, version, "user-100");
setId(skillVersion, 1L);
skillVersion.setStatus(SkillVersionStatus.PUBLISHED);
SkillFile file1 = new SkillFile(1L, "file1.txt", 100L, "text/plain", "hash1", "key1");
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
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);
List<SkillFile> result = service.listFiles(namespaceSlug, skillSlug, version, "user-100", userNsRoles);
// Assert
assertEquals(1, result.size());
assertEquals("file1.txt", result.get(0).getFilePath());
}
@Test
void testListFiles_ShouldRejectDraftVersion() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String version = "1.0.0";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
SkillVersion skillVersion = new SkillVersion(1L, version, "user-100");
setId(skillVersion, 1L);
skillVersion.setStatus(SkillVersionStatus.DRAFT);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
assertThrows(DomainBadRequestException.class, () ->
service.listFiles(namespaceSlug, skillSlug, version, "user-100", userNsRoles));
}
@Test
void testGetVersionDetail_ShouldReturnMetadataPayload() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String version = "1.0.0";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
SkillVersion skillVersion = new SkillVersion(1L, version, "user-100");
setId(skillVersion, 10L);
skillVersion.setStatus(SkillVersionStatus.PUBLISHED);
skillVersion.setParsedMetadataJson("{\"name\":\"test-skill\"}");
skillVersion.setManifestJson("[{\"path\":\"SKILL.md\"}]");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail(
namespaceSlug,
skillSlug,
version,
"user-100",
userNsRoles
);
assertEquals("{\"name\":\"test-skill\"}", result.parsedMetadataJson());
assertEquals("[{\"path\":\"SKILL.md\"}]", result.manifestJson());
}
@Test
void testListFilesByTag_ShouldResolveLatestTag() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
skill.setLatestVersionId(11L);
SkillVersion latestVersion = new SkillVersion(1L, "1.1.0", "user-100");
setId(latestVersion, 11L);
latestVersion.setStatus(SkillVersionStatus.PUBLISHED);
SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
List<SkillFile> result = service.listFilesByTag(namespaceSlug, skillSlug, "latest", "user-100", userNsRoles);
assertEquals(1, result.size());
assertEquals("README.md", result.get(0).getFilePath());
}
@Test
void testResolveVersion_ShouldReturnLatestWhenHashDoesNotMatch() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
skill.setLatestVersionId(10L);
SkillVersion version100 = new SkillVersion(1L, "1.0.0", "user-100");
setId(version100, 9L);
version100.setStatus(SkillVersionStatus.PUBLISHED);
SkillVersion version110 = new SkillVersion(1L, "1.1.0", "user-100");
setId(version110, 10L);
version110.setStatus(SkillVersionStatus.PUBLISHED);
SkillFile version100File = new SkillFile(9L, "SKILL.md", 10L, "text/markdown", "hash100", "key100");
SkillFile version110File = new SkillFile(10L, "SKILL.md", 10L, "text/markdown", "hash110", "key110");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
.thenReturn(List.of(version100, version110));
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version110));
when(skillFileRepository.findByVersionId(9L)).thenReturn(List.of(version100File));
when(skillFileRepository.findByVersionId(10L)).thenReturn(List.of(version110File));
SkillQueryService.ResolvedVersionDTO result = service.resolveVersion(
namespaceSlug,
skillSlug,
null,
null,
"sha256:does-not-match",
"user-100",
userNsRoles
);
assertEquals("1.1.0", result.version());
assertEquals(Boolean.FALSE, result.matched());
assertTrue(result.downloadUrl().contains("/versions/1.1.0/download"));
}
private void setId(Object entity, Long id) throws Exception {
Field idField = entity.getClass().getDeclaredField("id");
idField.setAccessible(true);

View file

@ -1,7 +1,12 @@
package com.iflytek.skillhub.domain.skill.service;
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.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -23,6 +28,8 @@ class SkillTagServiceTest {
@Mock
private NamespaceRepository namespaceRepository;
@Mock
private NamespaceMemberRepository namespaceMemberRepository;
@Mock
private SkillRepository skillRepository;
@Mock
private SkillVersionRepository skillVersionRepository;
@ -35,6 +42,7 @@ class SkillTagServiceTest {
void setUp() {
service = new SkillTagService(
namespaceRepository,
namespaceMemberRepository,
skillRepository,
skillVersionRepository,
skillTagRepository
@ -48,9 +56,9 @@ class SkillTagServiceTest {
String skillSlug = "test-skill";
String tagName = "stable";
String targetVersion = "1.0.0";
Long operatorId = 100L;
String operatorId = "user-100";
Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, operatorId, SkillVisibility.PUBLIC);
setId(skill, 1L);
@ -60,6 +68,8 @@ class SkillTagServiceTest {
SkillTag tag = new SkillTag(1L, tagName, 1L, operatorId);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId))
.thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.OWNER)));
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());
@ -80,10 +90,10 @@ class SkillTagServiceTest {
String skillSlug = "test-skill";
String tagName = "latest";
String targetVersion = "1.0.0";
Long operatorId = 100L;
String operatorId = "user-100";
// Act & Assert
assertThrows(IllegalArgumentException.class, () ->
assertThrows(DomainBadRequestException.class, () ->
service.createOrMoveTag(namespaceSlug, skillSlug, tagName, targetVersion, operatorId)
);
}
@ -94,15 +104,17 @@ class SkillTagServiceTest {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String tagName = "stable";
Long operatorId = 100L;
String operatorId = "user-100";
Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
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(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId))
.thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.ADMIN)));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag));
@ -119,26 +131,45 @@ class SkillTagServiceTest {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String tagName = "latest";
Long operatorId = 100L;
String operatorId = "user-100";
// Act & Assert
assertThrows(IllegalArgumentException.class, () ->
assertThrows(DomainBadRequestException.class, () ->
service.deleteTag(namespaceSlug, skillSlug, tagName, operatorId)
);
}
@Test
void testCreateTag_RequiresAdminOrOwner() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String tagName = "stable";
String targetVersion = "1.0.0";
String operatorId = "user-100";
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId))
.thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.MEMBER)));
assertThrows(DomainForbiddenException.class, () ->
service.createOrMoveTag(namespaceSlug, skillSlug, tagName, targetVersion, operatorId));
}
@Test
void testListTags() throws Exception {
// Arrange
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
Namespace namespace = new Namespace(namespaceSlug, "Test NS", 1L);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, 100L, SkillVisibility.PUBLIC);
Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC);
setId(skill, 1L);
SkillTag tag1 = new SkillTag(1L, "stable", 1L, 100L);
SkillTag tag2 = new SkillTag(1L, "beta", 2L, 100L);
SkillTag tag1 = new SkillTag(1L, "stable", 1L, "user-100");
SkillTag tag2 = new SkillTag(1L, "beta", 2L, "user-100");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));