mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-16 04:28:39 +00:00
Merge PR 950 for metadata consistency validation
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
commit
3b61d2365f
19 changed files with 328 additions and 20 deletions
|
|
@ -40,6 +40,7 @@ description: When to use # 必需,1-2 句话
|
|||
解析规则:
|
||||
- `name` 和 `description` 为必需字段,缺失则校验失败
|
||||
- `name` 映射为 `skill.slug`(首次发布时),后续版本不可变更
|
||||
- 可选的 `metadata.display-name` 映射为 `skill.display_name`,不参与 slug 生成。必须是字符串,去除首尾空白后最多 200 个字符;缺失、null 或空白时回退到 `name`。展示名沿用版本发布/审核流程,待审核更新不覆盖已发布标题;撤下最新版本并回退到旧版本时,标题和摘要同步恢复到该旧版本。
|
||||
- `description` 映射为 `skill.summary`
|
||||
- frontmatter 完整解析结果存入 `skill_version.parsed_metadata_json`
|
||||
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ Status: current code-aligned reference
|
|||
|
||||
### 4.8 Yank 后指针修正
|
||||
|
||||
- yank 已发布版本时,若命中当前 `latestVersionId`,必须重算 latest published pointer
|
||||
- yank 已发布版本时,若命中当前 `latestVersionId`,必须重算 latest published pointer,并从回退版本的 metadata 恢复展示名和摘要
|
||||
- 若仍有其他 `PUBLISHED` 版本,则指向最新一个
|
||||
- 若已无任何 `PUBLISHED` 版本,则 `latestVersionId = null`
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,26 @@ When packaging an existing skill, keep its required scripts, references,
|
|||
`agents/` metadata, and license files together. Pass that skill directory
|
||||
(`./my-skill` in this example) to the publish command below.
|
||||
|
||||
To show a human-readable or localized title in lists, search results, and detail pages,
|
||||
add the optional `metadata.display-name` field:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: example-skill
|
||||
description: An example skill for demonstration.
|
||||
metadata:
|
||||
display-name: 示例技能
|
||||
version: "1.0.0"
|
||||
---
|
||||
```
|
||||
|
||||
`name` still determines the slug, installation coordinate, and links. The display name
|
||||
must be a string of at most 200 characters after trimming surrounding whitespace.
|
||||
Missing, null, or blank display names fall back to `name`. Titles follow the version's
|
||||
review lifecycle: an update awaiting review does not replace an existing published title.
|
||||
No database migration is needed; existing skills can keep their `name` and publish a new
|
||||
version with this optional metadata.
|
||||
|
||||
2. **Publish via CLI (Recommended)**
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -68,6 +68,23 @@ version: 1.0.0
|
|||
Summarize the supplied text as numbered steps.
|
||||
```
|
||||
|
||||
如需在列表、搜索结果和详情页显示中文或其他易读名称,可添加可选的 `metadata.display-name`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: example-skill
|
||||
description: 用于演示的示例技能。
|
||||
metadata:
|
||||
display-name: 示例技能
|
||||
version: "1.0.0"
|
||||
---
|
||||
```
|
||||
|
||||
`name` 仍用于生成 slug、安装坐标和链接;`metadata.display-name` 仅用于展示。
|
||||
展示名必须是字符串,去除首尾空白后最多 200 个字符;未填写、为 null 或空白时回退到 `name`。
|
||||
展示名随技能版本发布:已有公开版本的标题在更新审核通过后才改变。
|
||||
无需数据库迁移;已有技能可保持原 `name`,增加展示名后发布新版本。
|
||||
|
||||
打包已有技能时,应一并保留其所需的脚本、参考文件、`agents/` 元数据和许可证文件。
|
||||
下一步发布命令的路径应指向该技能目录(本例为 `./my-skill`)。
|
||||
|
||||
|
|
|
|||
|
|
@ -335,3 +335,6 @@ error.authoring.stream.tooManySubscribers=Too many validation event subscribers.
|
|||
error.skill.avatar.uploadExpired=The uploaded avatar expired. Please upload it again.
|
||||
error.skill.avatar.invalidUpload=Select a valid uploaded avatar before submitting.
|
||||
error.skill.avatar.skillMismatch=The uploaded package does not match the skill being updated.
|
||||
|
||||
error.skill.metadata.displayName.invalid=metadata.display-name must be a string
|
||||
error.skill.metadata.displayName.tooLong=metadata.display-name must not exceed {0} characters
|
||||
|
|
|
|||
|
|
@ -278,3 +278,6 @@ error.authoring.stream.tooManySubscribers=Достигнут предел под
|
|||
error.skill.avatar.uploadExpired=Срок действия загрузки аватара истёк. Загрузите изображение снова.
|
||||
error.skill.avatar.invalidUpload=Перед отправкой выберите успешно загруженный аватар.
|
||||
error.skill.avatar.skillMismatch=Загруженный пакет не соответствует обновляемому навыку.
|
||||
|
||||
error.skill.metadata.displayName.invalid=metadata.display-name должно быть строкой
|
||||
error.skill.metadata.displayName.tooLong=metadata.display-name не должно превышать {0} символов
|
||||
|
|
|
|||
|
|
@ -337,3 +337,6 @@ error.authoring.stream.tooManySubscribers=校验事件连接数量已达上限
|
|||
error.skill.avatar.uploadExpired=头像上传引用已过期,请重新上传。
|
||||
error.skill.avatar.invalidUpload=请选择已上传成功的头像后再提交。
|
||||
error.skill.avatar.skillMismatch=上传的技能包与正在更新的技能不一致。
|
||||
|
||||
error.skill.metadata.displayName.invalid=metadata.display-name 必须是字符串
|
||||
error.skill.metadata.displayName.tooLong=metadata.display-name 不能超过 {0} 个字符
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import static org.mockito.Mockito.times;
|
|||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
|
||||
|
|
@ -130,6 +131,7 @@ class SkillYankLockingTest {
|
|||
eventPublisher,
|
||||
mock(SecurityScanService.class),
|
||||
mock(SkillStorageDeletionCompensationService.class),
|
||||
new ObjectMapper(),
|
||||
Clock.systemUTC());
|
||||
CountDownLatch firstAudited = new CountDownLatch(1);
|
||||
CountDownLatch secondReady = new CountDownLatch(1);
|
||||
|
|
|
|||
|
|
@ -413,7 +413,7 @@ public class ReviewService {
|
|||
|
||||
try {
|
||||
SkillMetadata metadata = objectMapper.readValue(metadataJson, SkillMetadata.class);
|
||||
skill.setDisplayName(metadata.name());
|
||||
skill.setDisplayName(metadata.displayName());
|
||||
skill.setSummary(metadata.description());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to deserialize skill metadata", e);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub.domain.skill.metadata;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import java.util.Map;
|
||||
|
||||
public record SkillMetadata(
|
||||
|
|
@ -8,4 +9,34 @@ public record SkillMetadata(
|
|||
String version,
|
||||
String body,
|
||||
Map<String, Object> frontmatter
|
||||
) {}
|
||||
) {
|
||||
public SkillMetadata {
|
||||
resolveDisplayName(name, frontmatter);
|
||||
}
|
||||
|
||||
/** Human-readable label; the package name remains the source of the skill slug. */
|
||||
public String displayName() {
|
||||
return resolveDisplayName(name, frontmatter);
|
||||
}
|
||||
|
||||
private static String resolveDisplayName(String name, Map<String, Object> frontmatter) {
|
||||
if (frontmatter == null || !(frontmatter.get("metadata") instanceof Map<?, ?> metadata)) {
|
||||
return name;
|
||||
}
|
||||
Object value = metadata.get("display-name");
|
||||
if (value == null) {
|
||||
return name;
|
||||
}
|
||||
if (!(value instanceof String text)) {
|
||||
throw new DomainBadRequestException("error.skill.metadata.displayName.invalid");
|
||||
}
|
||||
String displayName = text.strip();
|
||||
if (displayName.isEmpty()) {
|
||||
return name;
|
||||
}
|
||||
if (displayName.length() > 200) {
|
||||
throw new DomainBadRequestException("error.skill.metadata.displayName.tooLong", 200);
|
||||
}
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub.domain.skill.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.audit.AuditDetail;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent;
|
||||
|
|
@ -17,12 +18,14 @@ import com.iflytek.skillhub.domain.skill.SkillStatus;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
|
@ -49,6 +52,7 @@ public class SkillGovernanceService {
|
|||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final SecurityScanService securityScanService;
|
||||
private final SkillStorageDeletionCompensationService compensationService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Clock clock;
|
||||
|
||||
public SkillGovernanceService(SkillRepository skillRepository,
|
||||
|
|
@ -60,6 +64,7 @@ public class SkillGovernanceService {
|
|||
ApplicationEventPublisher eventPublisher,
|
||||
SecurityScanService securityScanService,
|
||||
SkillStorageDeletionCompensationService compensationService,
|
||||
ObjectMapper objectMapper,
|
||||
Clock clock) {
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
|
|
@ -70,6 +75,7 @@ public class SkillGovernanceService {
|
|||
this.eventPublisher = eventPublisher;
|
||||
this.securityScanService = securityScanService;
|
||||
this.compensationService = compensationService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
|
|
@ -305,7 +311,9 @@ public class SkillGovernanceService {
|
|||
SkillVersion saved = skillVersionRepository.save(version);
|
||||
skillRepository.findById(version.getSkillId()).ifPresent(skill -> {
|
||||
if (versionId.equals(skill.getLatestVersionId())) {
|
||||
skill.setLatestVersionId(findLatestPublishedVersionId(skill.getId()));
|
||||
Optional<SkillVersion> fallback = findLatestPublishedVersion(skill.getId());
|
||||
skill.setLatestVersionId(fallback.map(SkillVersion::getId).orElse(null));
|
||||
fallback.ifPresent(published -> applyPublishedMetadata(skill, published));
|
||||
skill.setUpdatedBy(actorUserId);
|
||||
skillRepository.save(skill);
|
||||
}
|
||||
|
|
@ -317,13 +325,29 @@ public class SkillGovernanceService {
|
|||
}
|
||||
|
||||
private Long findLatestPublishedVersionId(Long skillId) {
|
||||
return findLatestPublishedVersion(skillId).map(SkillVersion::getId).orElse(null);
|
||||
}
|
||||
|
||||
private Optional<SkillVersion> findLatestPublishedVersion(Long skillId) {
|
||||
return skillVersionRepository.findBySkillIdAndStatus(skillId, SkillVersionStatus.PUBLISHED).stream()
|
||||
.max(java.util.Comparator
|
||||
.comparing(SkillVersion::getPublishedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
|
||||
.thenComparing(SkillVersion::getCreatedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
|
||||
.thenComparing(SkillVersion::getId, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())))
|
||||
.map(SkillVersion::getId)
|
||||
.orElse(null);
|
||||
.thenComparing(SkillVersion::getId, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())));
|
||||
}
|
||||
|
||||
private void applyPublishedMetadata(Skill skill, SkillVersion version) {
|
||||
String metadataJson = version.getParsedMetadataJson();
|
||||
if (metadataJson == null || metadataJson.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
SkillMetadata metadata = objectMapper.readValue(metadataJson, SkillMetadata.class);
|
||||
skill.setDisplayName(metadata.displayName());
|
||||
skill.setSummary(metadata.description());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to deserialize skill metadata", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertCanManageLifecycle(Skill skill,
|
||||
|
|
|
|||
|
|
@ -708,8 +708,11 @@ public class SkillPublishService {
|
|||
}
|
||||
|
||||
// 12. Update skill metadata and move the published pointer for auto-publish flows
|
||||
skill.setDisplayName(metadata.name());
|
||||
skill.setSummary(metadata.description());
|
||||
// A pending update must not replace the currently published title or summary.
|
||||
if (autoPublish || visibility == SkillVisibility.PRIVATE || skill.getLatestVersionId() == null) {
|
||||
skill.setDisplayName(metadata.displayName());
|
||||
skill.setSummary(metadata.description());
|
||||
}
|
||||
if (autoPublish || visibility == SkillVisibility.PRIVATE) {
|
||||
// Update latestVersionId for autoPublish or PRIVATE skill (UPLOADED status)
|
||||
skill.setLatestVersionId(version.getId());
|
||||
|
|
|
|||
|
|
@ -225,8 +225,9 @@ class ReviewServiceTest {
|
|||
@Nested
|
||||
class ApproveReview {
|
||||
|
||||
@Test
|
||||
void shouldApproveReviewSuccessfully() {
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void shouldApproveReviewSuccessfully(boolean hasDisplayName) {
|
||||
ReviewTask task = createPendingReviewTask();
|
||||
Namespace ns = createTeamNamespace();
|
||||
SkillVersion sv = createPendingReviewSkillVersion();
|
||||
|
|
@ -235,7 +236,8 @@ class ReviewServiceTest {
|
|||
skill.setSummary("Published Summary");
|
||||
skill.setUpdatedBy("previous-reviewer");
|
||||
assertDoesNotThrow(() -> sv.setParsedMetadataJson(objectMapper.writeValueAsString(
|
||||
new SkillMetadata("Approved Name", "Approved Summary", "1.0.0", "Body", Map.of())
|
||||
new SkillMetadata("Approved Name", "Approved Summary", "1.0.0", "Body",
|
||||
hasDisplayName ? Map.of("metadata", Map.of("display-name", "已审核的中文标题")) : Map.of())
|
||||
)));
|
||||
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
|
@ -262,7 +264,8 @@ class ReviewServiceTest {
|
|||
assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus());
|
||||
assertEquals(Instant.now(CLOCK), sv.getPublishedAt());
|
||||
assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId());
|
||||
assertEquals("Approved Name", skill.getDisplayName());
|
||||
assertEquals(hasDisplayName ? "已审核的中文标题" : "Approved Name", skill.getDisplayName());
|
||||
assertEquals("my-skill", skill.getSlug());
|
||||
assertEquals("Approved Summary", skill.getSummary());
|
||||
assertEquals(REVIEWER_ID, skill.getUpdatedBy());
|
||||
verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
|
||||
|
|
|
|||
|
|
@ -11,6 +11,56 @@ class SkillMetadataParserTest {
|
|||
|
||||
private final SkillMetadataParser parser = new SkillMetadataParser();
|
||||
|
||||
@Test
|
||||
void parsesDisplayNameWithoutChangingPackageName() {
|
||||
SkillMetadata metadata = parser.parse("""
|
||||
---
|
||||
name: example-skill
|
||||
description: An example skill for demonstration
|
||||
metadata:
|
||||
version: "1.0.0"
|
||||
display-name: " 示例技能 "
|
||||
---
|
||||
Instructions
|
||||
""");
|
||||
|
||||
assertEquals("示例技能", metadata.displayName());
|
||||
assertEquals("example-skill", metadata.name());
|
||||
assertEquals("1.0.0", metadata.version());
|
||||
assertEquals("示例技能", new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.convertValue(metadata, SkillMetadata.class).displayName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToPackageNameForMissingOrBlankDisplayName() {
|
||||
for (String extra : new String[] {"", "metadata: {}", "metadata:\n display-name: null",
|
||||
"metadata:\n display-name: ' '"}) {
|
||||
SkillMetadata metadata = parser.parse("---\nname: my-skill\ndescription: Test\n"
|
||||
+ extra + "\n---\nBody");
|
||||
assertEquals("my-skill", metadata.displayName());
|
||||
}
|
||||
assertEquals("old-skill", new SkillMetadata("old-skill", "Test", "1.0.0", "Body", null).displayName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonStringDisplayNames() {
|
||||
for (String value : new String[] {"123", "true", "[one, two]", "{label: test}"}) {
|
||||
DomainBadRequestException error = assertThrows(DomainBadRequestException.class,
|
||||
() -> parser.parse("---\nname: my-skill\ndescription: Test\nmetadata:\n display-name: "
|
||||
+ value + "\n---\nBody"));
|
||||
assertEquals("error.skill.metadata.displayName.invalid", error.messageCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesDisplayNameLengthBeforePublishing() {
|
||||
String template = "---\nname: my-skill\ndescription: Test\nmetadata:\n display-name: %s\n---\nBody";
|
||||
assertEquals("中".repeat(200), parser.parse(template.formatted("中".repeat(200))).displayName());
|
||||
DomainBadRequestException error = assertThrows(DomainBadRequestException.class,
|
||||
() -> parser.parse(template.formatted("中".repeat(201))));
|
||||
assertEquals("error.skill.metadata.displayName.tooLong", error.messageCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParseStandardFrontmatterAndBody() {
|
||||
String content = """
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import static org.mockito.Mockito.inOrder;
|
|||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
|
|
@ -36,6 +37,8 @@ import org.junit.jupiter.api.AfterEach;
|
|||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
|
@ -81,6 +84,7 @@ class SkillGovernanceServiceTest {
|
|||
eventPublisher,
|
||||
securityScanService,
|
||||
compensationService,
|
||||
new ObjectMapper(),
|
||||
CLOCK
|
||||
);
|
||||
}
|
||||
|
|
@ -250,8 +254,9 @@ class SkillGovernanceServiceTest {
|
|||
verify(objectStorageService, never()).deleteObject(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void yankVersion_recomputesLatestPublishedPointer() {
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void yankVersion_restoresLatestPublishedMetadata(boolean hasDisplayName) throws Exception {
|
||||
SkillVersion yanked = new SkillVersion(2L, "2.0.0", "owner");
|
||||
setField(yanked, "id", 22L);
|
||||
yanked.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
|
@ -261,10 +266,18 @@ class SkillGovernanceServiceTest {
|
|||
setField(fallback, "id", 11L);
|
||||
fallback.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
fallback.setPublishedAt(Instant.parse("2026-03-17T10:00:00Z"));
|
||||
fallback.setParsedMetadataJson(new ObjectMapper().writeValueAsString(Map.of(
|
||||
"name", "example-skill",
|
||||
"description", "Published summary from v1",
|
||||
"frontmatter", hasDisplayName
|
||||
? Map.of("metadata", Map.of("display-name", "示例技能一"))
|
||||
: Map.of())));
|
||||
|
||||
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 2L);
|
||||
skill.setLatestVersionId(22L);
|
||||
skill.setDisplayName("示例技能二");
|
||||
skill.setSummary("Published summary from v2");
|
||||
|
||||
given(skillVersionRepository.findByIdForUpdate(22L)).willReturn(Optional.of(yanked));
|
||||
given(skillVersionRepository.save(yanked)).willReturn(yanked);
|
||||
|
|
@ -275,9 +288,57 @@ class SkillGovernanceServiceTest {
|
|||
service.yankVersion(22L, "admin", "127.0.0.1", "JUnit", "broken");
|
||||
|
||||
assertThat(skill.getLatestVersionId()).isEqualTo(11L);
|
||||
assertThat(skill.getDisplayName()).isEqualTo(hasDisplayName ? "示例技能一" : "example-skill");
|
||||
assertThat(skill.getSummary()).isEqualTo("Published summary from v1");
|
||||
verify(skillRepository).save(skill);
|
||||
}
|
||||
|
||||
@Test
|
||||
void yankVersion_preservesMetadataWhenYankingAnOlderVersion() {
|
||||
SkillVersion older = new SkillVersion(2L, "1.0.0", "owner");
|
||||
setField(older, "id", 11L);
|
||||
older.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
Skill skill = new Skill(1L, "example-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 2L);
|
||||
skill.setLatestVersionId(22L);
|
||||
skill.setDisplayName("示例技能二");
|
||||
skill.setSummary("Published summary from v2");
|
||||
given(skillVersionRepository.findByIdForUpdate(11L)).willReturn(Optional.of(older));
|
||||
given(skillVersionRepository.save(older)).willReturn(older);
|
||||
given(skillRepository.findById(2L)).willReturn(Optional.of(skill));
|
||||
|
||||
service.yankVersion(11L, "admin", "127.0.0.1", "JUnit", "broken");
|
||||
|
||||
assertThat(skill.getLatestVersionId()).isEqualTo(22L);
|
||||
assertThat(skill.getDisplayName()).isEqualTo("示例技能二");
|
||||
assertThat(skill.getSummary()).isEqualTo("Published summary from v2");
|
||||
verify(skillRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void yankVersion_keepsHistoricalMetadataWhenNoPublishedVersionRemains() {
|
||||
SkillVersion version = new SkillVersion(2L, "1.0.0", "owner");
|
||||
setField(version, "id", 11L);
|
||||
version.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
Skill skill = new Skill(1L, "example-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 2L);
|
||||
skill.setLatestVersionId(11L);
|
||||
skill.setDisplayName("示例技能");
|
||||
skill.setSummary("Historical summary");
|
||||
given(skillVersionRepository.findByIdForUpdate(11L)).willReturn(Optional.of(version));
|
||||
given(skillVersionRepository.save(version)).willReturn(version);
|
||||
given(skillRepository.findById(2L)).willReturn(Optional.of(skill));
|
||||
given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED))
|
||||
.willReturn(java.util.List.of());
|
||||
given(skillRepository.save(skill)).willReturn(skill);
|
||||
|
||||
service.yankVersion(11L, "admin", "127.0.0.1", "JUnit", "broken");
|
||||
|
||||
assertThat(skill.getLatestVersionId()).isNull();
|
||||
assertThat(skill.getDisplayName()).isEqualTo("示例技能");
|
||||
assertThat(skill.getSummary()).isEqualTo("Historical summary");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteVersion_removesDraftFilesAndBundle() {
|
||||
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
|
|
|
|||
|
|
@ -1931,6 +1931,45 @@ class SkillPublishServiceTest {
|
|||
verify(skillVersionRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishesDisplayNameWithoutChangingSlugOrVersionMetadataName() throws Exception {
|
||||
PublishFixture fixture = stubValidPublishInputs("test-ns", "user-100", "test-skill", "test-skill", "1.0.0", false);
|
||||
when(skillMetadataParser.parse(anyString())).thenReturn(new SkillMetadata(
|
||||
"test-skill", "Test", "1.0.0", "Body",
|
||||
Map.of("metadata", Map.of("display-name", "示例技能"))));
|
||||
|
||||
SkillPublishService.PublishResult result = service.publishFromEntries(
|
||||
"test-ns", fixture.entries(), "user-100", SkillVisibility.PUBLIC, Set.of("SUPER_ADMIN"));
|
||||
|
||||
assertEquals("test-skill", result.slug());
|
||||
ArgumentCaptor<Skill> saved = ArgumentCaptor.forClass(Skill.class);
|
||||
verify(skillRepository).save(saved.capture());
|
||||
assertEquals("示例技能", saved.getValue().getDisplayName());
|
||||
JsonNode snapshot = objectMapper.readTree(result.version().getParsedMetadataJson());
|
||||
assertEquals("test-skill", snapshot.path("name").asText());
|
||||
assertEquals("示例技能", snapshot.path("frontmatter").path("metadata").path("display-name").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingUpdateKeepsPublishedDisplayNameAndSummary() throws Exception {
|
||||
PublishFixture fixture = stubValidPublishInputs("test-ns", "user-100", "test-skill", "test-skill", "2.0.0", true);
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "test-skill", "user-100").orElseThrow();
|
||||
skill.setLatestVersionId(9L);
|
||||
skill.setDisplayName("已审核的标题");
|
||||
skill.setSummary("Published summary");
|
||||
when(skillMetadataParser.parse(anyString())).thenReturn(new SkillMetadata(
|
||||
"test-skill", "Pending summary", "2.0.0", "Body",
|
||||
Map.of("metadata", Map.of("display-name", "待审核的标题"))));
|
||||
|
||||
SkillPublishService.PublishResult result = service.publishFromEntries(
|
||||
"test-ns", fixture.entries(), "user-100", SkillVisibility.PUBLIC, Set.of());
|
||||
|
||||
assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
|
||||
assertEquals("已审核的标题", skill.getDisplayName());
|
||||
assertEquals("Published summary", skill.getSummary());
|
||||
assertEquals(9L, skill.getLatestVersionId());
|
||||
}
|
||||
|
||||
private record PublishFixture(List<PackageEntry> entries) {
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ interface ApiFailure extends Error {
|
|||
const cleanupTimeoutMs = process.env.CI ? 8_000 : 5_000
|
||||
|
||||
export interface SeedSkillOptions {
|
||||
displayName?: string
|
||||
name?: string
|
||||
description?: string
|
||||
version?: string
|
||||
|
|
@ -109,7 +110,7 @@ function buildSkillPackageContent(suffix: string, options?: SeedSkillOptions) {
|
|||
name: ${skillName}
|
||||
description: ${description}
|
||||
version: ${version}
|
||||
---
|
||||
${options?.displayName === undefined ? '' : `metadata:\n display-name: ${JSON.stringify(options.displayName)}\n`}---
|
||||
|
||||
# ${readmeHeading}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
import { registerSession } from './helpers/session'
|
||||
import { loginWithCredentials, registerSession } from './helpers/session'
|
||||
import { E2eTestDataBuilder } from './helpers/test-data-builder'
|
||||
|
||||
test.describe('My Skills Navigation (Real API)', () => {
|
||||
|
|
@ -9,22 +9,46 @@ test.describe('My Skills Navigation (Real API)', () => {
|
|||
await registerSession(page, testInfo)
|
||||
})
|
||||
|
||||
test('opens seeded skill detail from dashboard list and returns back', async ({ page }, testInfo) => {
|
||||
test('shows a localized title while navigating with the original skill slug', async ({ page, browser }, testInfo) => {
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
await builder.init()
|
||||
|
||||
try {
|
||||
const namespace = await builder.ensureWritableNamespace()
|
||||
const skill = await builder.publishSkill(namespace.slug)
|
||||
const displayName = '示例技能'
|
||||
const skill = await builder.publishSkill(namespace.slug, { displayName })
|
||||
|
||||
await page.goto('/dashboard/skills')
|
||||
await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible()
|
||||
|
||||
await page.getByText(`@${skill.namespace}`).first().click()
|
||||
await expect(page.getByRole('heading', { name: displayName, exact: true })).toBeVisible()
|
||||
await page.getByRole('heading', { name: displayName, exact: true }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/space/${skill.namespace}/${skill.slug}\\?returnTo=%2Fdashboard%2Fskills$`))
|
||||
|
||||
await expect(page.getByRole('heading', { name: displayName, exact: true })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Back' }).click()
|
||||
await expect(page).toHaveURL('/dashboard/skills')
|
||||
|
||||
const adminContext = await browser.newContext()
|
||||
try {
|
||||
const adminPage = await adminContext.newPage()
|
||||
await loginWithCredentials(adminPage, {
|
||||
username: process.env.E2E_ADMIN_USERNAME ?? process.env.BOOTSTRAP_ADMIN_USERNAME ?? 'admin',
|
||||
password: process.env.E2E_ADMIN_PASSWORD ?? process.env.BOOTSTRAP_ADMIN_PASSWORD ?? 'ChangeMe!2026',
|
||||
}, testInfo)
|
||||
const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
|
||||
await adminBuilder.init()
|
||||
const reviewId = await adminBuilder.waitForPendingReview(skill.namespace, skill.slug, skill.version)
|
||||
await adminBuilder.approveReview(reviewId)
|
||||
} finally {
|
||||
await adminContext.close()
|
||||
}
|
||||
|
||||
// The Chinese query appears only in display-name, not the slug or description.
|
||||
await builder.waitForSearchResult(displayName, skill.slug)
|
||||
await page.goto(`/search?q=${encodeURIComponent(displayName)}`)
|
||||
await expect(page.getByRole('heading', { name: displayName, exact: true })).toBeVisible()
|
||||
} finally {
|
||||
await builder.cleanup()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,29 @@ describe('MySkillsPage', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('renders a localized display name independently of the package slug', () => {
|
||||
useMySkillsMock.mockReturnValue({
|
||||
data: {
|
||||
items: [{
|
||||
id: 1,
|
||||
displayName: '示例技能',
|
||||
namespace: 'team-ai',
|
||||
slug: 'example-skill',
|
||||
downloadCount: 0,
|
||||
status: 'ACTIVE',
|
||||
visibility: 'PUBLIC',
|
||||
}],
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 10,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const markup = renderToStaticMarkup(createElement(MySkillsPage))
|
||||
expect(markup).toMatch(/<h3[^>]*>示例技能<\/h3>/)
|
||||
})
|
||||
|
||||
it('navigates to publish page with namespace and visibility when update is clicked', () => {
|
||||
renderToStaticMarkup(createElement(MySkillsPage))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue