mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
feat(api): return skill labels from the skill listing endpoints on request
Skill labels were reachable only one skill at a time, through
/api/{v1,web}/skills/{namespace}/{slug}/labels, so a client rendering a list had
to issue a follow-up request per row.
Add includeLabels=true to GET /api/v1/skills and GET /api/web/skills. The labels
array is populated only when the parameter is set and left out of the payload
otherwise, so existing responses are byte-identical.
Labels for the whole page are resolved by SkillLabelProjectionService in three
queries — assignments, definitions, translations — rather than three per skill.
Closes #710
Signed-off-by: FenjuFu <fufenjupku@gmail.com>
This commit is contained in:
parent
d2403bb591
commit
3329900487
10 changed files with 390 additions and 10 deletions
|
|
@ -20,8 +20,10 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
|||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
|
||||
import com.iflytek.skillhub.domain.social.SkillStarService;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||
import com.iflytek.skillhub.service.SkillLabelProjectionService;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
|
|
@ -51,6 +53,7 @@ public class ClawHubCompatAppService {
|
|||
private final CompatSkillLookupService compatSkillLookupService;
|
||||
private final SkillStarService skillStarService;
|
||||
private final RequestIdAccessor requestIdAccessor;
|
||||
private final SkillLabelProjectionService skillLabelProjectionService;
|
||||
|
||||
public ClawHubCompatAppService(CanonicalSlugMapper mapper,
|
||||
SkillSearchAppService skillSearchAppService,
|
||||
|
|
@ -61,7 +64,8 @@ public class ClawHubCompatAppService {
|
|||
AuditLogService auditLogService,
|
||||
CompatSkillLookupService compatSkillLookupService,
|
||||
SkillStarService skillStarService,
|
||||
RequestIdAccessor requestIdAccessor) {
|
||||
RequestIdAccessor requestIdAccessor,
|
||||
SkillLabelProjectionService skillLabelProjectionService) {
|
||||
this.mapper = mapper;
|
||||
this.skillSearchAppService = skillSearchAppService;
|
||||
this.skillQueryService = skillQueryService;
|
||||
|
|
@ -72,6 +76,7 @@ public class ClawHubCompatAppService {
|
|||
this.compatSkillLookupService = compatSkillLookupService;
|
||||
this.skillStarService = skillStarService;
|
||||
this.requestIdAccessor = requestIdAccessor;
|
||||
this.skillLabelProjectionService = skillLabelProjectionService;
|
||||
}
|
||||
|
||||
public ClawHubSearchResponse search(String q,
|
||||
|
|
@ -195,6 +200,15 @@ public class ClawHubCompatAppService {
|
|||
String sort,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
return listSkills(page, limit, sort, false, userId, userNsRoles);
|
||||
}
|
||||
|
||||
public ClawHubSkillListResponse listSkills(int page,
|
||||
int limit,
|
||||
String sort,
|
||||
boolean includeLabels,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
String sortBy = sort != null ? sort : "newest";
|
||||
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
|
||||
"",
|
||||
|
|
@ -206,8 +220,15 @@ public class ClawHubCompatAppService {
|
|||
userNsRoles
|
||||
);
|
||||
|
||||
Map<Long, List<SkillLabelDto>> labelsBySkillId = includeLabels
|
||||
? skillLabelProjectionService.labelsBySkillIds(
|
||||
response.items().stream().map(SkillSummaryResponse::id).toList())
|
||||
: Map.of();
|
||||
|
||||
List<ClawHubSkillListResponse.SkillListItem> items = response.items().stream()
|
||||
.map(this::toSkillListItem)
|
||||
.map(item -> toSkillListItem(
|
||||
item,
|
||||
includeLabels ? labelsBySkillId.getOrDefault(item.id(), List.of()) : null))
|
||||
.toList();
|
||||
|
||||
String nextCursor = null;
|
||||
|
|
@ -383,7 +404,8 @@ public class ClawHubCompatAppService {
|
|||
return new ClawHubResolveResponse(matchVersion, latestVersion);
|
||||
}
|
||||
|
||||
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) {
|
||||
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item,
|
||||
List<SkillLabelDto> labels) {
|
||||
long createdAt = 0;
|
||||
long updatedAt = item.updatedAt() != null ? item.updatedAt().toEpochMilli() : 0;
|
||||
|
||||
|
|
@ -413,7 +435,8 @@ public class ClawHubCompatAppService {
|
|||
stats,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
latestVersion
|
||||
latestVersion,
|
||||
labels
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,9 +93,10 @@ public class ClawHubCompatController {
|
|||
public ClawHubSkillListResponse listSkills(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int limit,
|
||||
@RequestParam(required = false) String sort,
|
||||
@RequestParam(defaultValue = "false") boolean includeLabels,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return clawHubCompatAppService.listSkills(page, limit, sort, userId, userNsRoles);
|
||||
return clawHubCompatAppService.listSkills(page, limit, sort, includeLabels, userId, userNsRoles);
|
||||
}
|
||||
|
||||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import java.util.List;
|
||||
|
||||
public record ClawHubSkillListResponse(
|
||||
|
|
@ -14,8 +16,27 @@ public record ClawHubSkillListResponse(
|
|||
Object stats,
|
||||
long createdAt,
|
||||
long updatedAt,
|
||||
LatestVersion latestVersion
|
||||
LatestVersion latestVersion,
|
||||
/**
|
||||
* Labels attached to the skill, present only when the caller passes
|
||||
* {@code includeLabels=true}. Omitted otherwise, so the legacy payload is unchanged.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
List<SkillLabelDto> labels
|
||||
) {
|
||||
|
||||
public SkillListItem(
|
||||
String slug,
|
||||
String displayName,
|
||||
String summary,
|
||||
Object tags,
|
||||
Object stats,
|
||||
long createdAt,
|
||||
long updatedAt,
|
||||
LatestVersion latestVersion) {
|
||||
this(slug, displayName, summary, tags, stats, createdAt, updatedAt, latestVersion, null);
|
||||
}
|
||||
|
||||
public record LatestVersion(
|
||||
String version,
|
||||
long createdAt,
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
|||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.service.SkillLabelProjectionService;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
|
@ -27,11 +31,14 @@ public class SkillSearchController extends BaseApiController {
|
|||
private static final int DEFAULT_SIZE = 20;
|
||||
|
||||
private final SkillSearchAppService skillSearchAppService;
|
||||
private final SkillLabelProjectionService skillLabelProjectionService;
|
||||
|
||||
public SkillSearchController(SkillSearchAppService skillSearchAppService,
|
||||
SkillLabelProjectionService skillLabelProjectionService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.skillSearchAppService = skillSearchAppService;
|
||||
this.skillLabelProjectionService = skillLabelProjectionService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
|
|
@ -40,6 +47,8 @@ public class SkillSearchController extends BaseApiController {
|
|||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) String namespace,
|
||||
@RequestParam(name = "label", required = false) java.util.List<String> labels,
|
||||
@Parameter(description = "Include each skill's labels in the response")
|
||||
@RequestParam(name = "includeLabels", defaultValue = "false") boolean includeLabels,
|
||||
@Parameter(schema = @Schema(defaultValue = DEFAULT_SORT))
|
||||
@RequestParam(required = false) String sort,
|
||||
@Parameter(schema = @Schema(type = "integer", defaultValue = "0", minimum = "0"))
|
||||
|
|
@ -60,7 +69,18 @@ public class SkillSearchController extends BaseApiController {
|
|||
userNsRoles
|
||||
);
|
||||
|
||||
return ok("response.success.read", response);
|
||||
return ok("response.success.read", includeLabels ? withLabels(response) : response);
|
||||
}
|
||||
|
||||
private SkillSearchAppService.SearchResponse withLabels(SkillSearchAppService.SearchResponse response) {
|
||||
Map<Long, List<SkillLabelDto>> labelsBySkillId = skillLabelProjectionService.labelsBySkillIds(
|
||||
response.items().stream().map(SkillSummaryResponse::id).toList());
|
||||
|
||||
List<SkillSummaryResponse> items = response.items().stream()
|
||||
.map(item -> item.withLabels(labelsBySkillId.getOrDefault(item.id(), List.of())))
|
||||
.toList();
|
||||
|
||||
return new SkillSearchAppService.SearchResponse(items, response.total(), response.page(), response.size());
|
||||
}
|
||||
|
||||
private String normalizeSort(String sort) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record SkillSummaryResponse(
|
||||
Long id,
|
||||
|
|
@ -21,5 +23,46 @@ public record SkillSummaryResponse(
|
|||
SkillLifecycleVersionResponse publishedVersion,
|
||||
SkillLifecycleVersionResponse ownerPreviewVersion,
|
||||
String resolutionMode,
|
||||
ComplianceSnapshotResponse complianceSnapshot
|
||||
) {}
|
||||
ComplianceSnapshotResponse complianceSnapshot,
|
||||
/**
|
||||
* Labels attached to the skill, present only when the caller asked for them.
|
||||
* Left out of the payload otherwise, so responses are unchanged for callers
|
||||
* that do not opt in.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
List<SkillLabelDto> labels
|
||||
) {
|
||||
|
||||
/**
|
||||
* Summary without label projection.
|
||||
*/
|
||||
public SkillSummaryResponse(
|
||||
Long id,
|
||||
String slug,
|
||||
String displayName,
|
||||
String summary,
|
||||
String visibility,
|
||||
String status,
|
||||
Long downloadCount,
|
||||
Integer starCount,
|
||||
BigDecimal ratingAvg,
|
||||
Integer ratingCount,
|
||||
String namespace,
|
||||
Instant updatedAt,
|
||||
boolean canSubmitPromotion,
|
||||
SkillLifecycleVersionResponse headlineVersion,
|
||||
SkillLifecycleVersionResponse publishedVersion,
|
||||
SkillLifecycleVersionResponse ownerPreviewVersion,
|
||||
String resolutionMode,
|
||||
ComplianceSnapshotResponse complianceSnapshot) {
|
||||
this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg,
|
||||
ratingCount, namespace, updatedAt, canSubmitPromotion, headlineVersion, publishedVersion,
|
||||
ownerPreviewVersion, resolutionMode, complianceSnapshot, null);
|
||||
}
|
||||
|
||||
public SkillSummaryResponse withLabels(List<SkillLabelDto> labels) {
|
||||
return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount,
|
||||
starCount, ratingAvg, ratingCount, namespace, updatedAt, canSubmitPromotion, headlineVersion,
|
||||
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.label.LabelDefinition;
|
||||
import com.iflytek.skillhub.domain.label.LabelDefinitionService;
|
||||
import com.iflytek.skillhub.domain.label.LabelTranslation;
|
||||
import com.iflytek.skillhub.domain.label.SkillLabel;
|
||||
import com.iflytek.skillhub.domain.label.SkillLabelService;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Projects skill labels for a whole page of skills in a fixed number of queries.
|
||||
*
|
||||
* <p>Listing endpoints need labels for every item they return, so resolving them one
|
||||
* skill at a time would issue three queries per row. This service batches the
|
||||
* assignment, definition, and translation lookups instead.</p>
|
||||
*/
|
||||
@Service
|
||||
public class SkillLabelProjectionService {
|
||||
|
||||
private final SkillLabelService skillLabelService;
|
||||
private final LabelDefinitionService labelDefinitionService;
|
||||
private final LabelLocalizationService labelLocalizationService;
|
||||
|
||||
public SkillLabelProjectionService(SkillLabelService skillLabelService,
|
||||
LabelDefinitionService labelDefinitionService,
|
||||
LabelLocalizationService labelLocalizationService) {
|
||||
this.skillLabelService = skillLabelService;
|
||||
this.labelDefinitionService = labelDefinitionService;
|
||||
this.labelLocalizationService = labelLocalizationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels for each requested skill, keyed by skill id. Skills without labels are absent
|
||||
* from the map rather than mapped to an empty list.
|
||||
*/
|
||||
public Map<Long, List<SkillLabelDto>> labelsBySkillIds(List<Long> skillIds) {
|
||||
if (skillIds == null || skillIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
List<Long> distinctSkillIds = skillIds.stream()
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (distinctSkillIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
List<SkillLabel> assignments = skillLabelService.listSkillLabelsBySkillIds(distinctSkillIds);
|
||||
if (assignments.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
List<Long> labelIds = assignments.stream()
|
||||
.map(SkillLabel::getLabelId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, LabelDefinition> definitionsById = labelDefinitionService.listByIds(labelIds).stream()
|
||||
.collect(Collectors.toMap(LabelDefinition::getId, Function.identity()));
|
||||
Map<Long, List<LabelTranslation>> translationsByLabelId =
|
||||
labelDefinitionService.listTranslationsByLabelIds(labelIds);
|
||||
|
||||
return assignments.stream()
|
||||
.filter(assignment -> definitionsById.containsKey(assignment.getLabelId()))
|
||||
.collect(Collectors.groupingBy(
|
||||
SkillLabel::getSkillId,
|
||||
Collectors.collectingAndThen(
|
||||
Collectors.toList(),
|
||||
skillAssignments -> skillAssignments.stream()
|
||||
.map(assignment -> toDto(
|
||||
definitionsById.get(assignment.getLabelId()),
|
||||
translationsByLabelId))
|
||||
.sorted(Comparator.comparing(SkillLabelDto::type)
|
||||
.thenComparing(SkillLabelDto::slug))
|
||||
.toList())));
|
||||
}
|
||||
|
||||
private SkillLabelDto toDto(LabelDefinition definition,
|
||||
Map<Long, List<LabelTranslation>> translationsByLabelId) {
|
||||
return new SkillLabelDto(
|
||||
definition.getSlug(),
|
||||
definition.getType().name(),
|
||||
labelLocalizationService.resolveDisplayName(
|
||||
definition.getSlug(),
|
||||
translationsByLabelId.getOrDefault(definition.getId(), List.of()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,8 +15,13 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
|||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
|
||||
import com.iflytek.skillhub.domain.social.SkillStarService;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubSkillListResponse;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||
import com.iflytek.skillhub.service.SkillLabelProjectionService;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -31,6 +36,7 @@ class ClawHubCompatAppServiceTest {
|
|||
private final AuditLogService auditLogService = mock(AuditLogService.class);
|
||||
private final CompatSkillLookupService compatSkillLookupService = mock(CompatSkillLookupService.class);
|
||||
private final SkillStarService skillStarService = mock(SkillStarService.class);
|
||||
private final SkillLabelProjectionService skillLabelProjectionService = mock(SkillLabelProjectionService.class);
|
||||
|
||||
private final ClawHubCompatAppService service = new ClawHubCompatAppService(
|
||||
new CanonicalSlugMapper(),
|
||||
|
|
@ -42,7 +48,8 @@ class ClawHubCompatAppServiceTest {
|
|||
auditLogService,
|
||||
compatSkillLookupService,
|
||||
skillStarService,
|
||||
new RequestIdAccessor()
|
||||
new RequestIdAccessor(),
|
||||
skillLabelProjectionService
|
||||
);
|
||||
|
||||
@Test
|
||||
|
|
@ -120,4 +127,35 @@ class ClawHubCompatAppServiceTest {
|
|||
|
||||
assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/versions/20260707.025847/download");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listSkills_omitsLabelsByDefault() {
|
||||
when(skillSearchAppService.search("", null, "newest", 0, 25, null, Map.of()))
|
||||
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25));
|
||||
|
||||
ClawHubSkillListResponse response = service.listSkills(0, 25, null, null, Map.of());
|
||||
|
||||
assertThat(response.items()).hasSize(1);
|
||||
assertThat(response.items().get(0).labels()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listSkills_returnsLabelsWhenRequested() {
|
||||
when(skillSearchAppService.search("", null, "newest", 0, 25, null, Map.of()))
|
||||
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25));
|
||||
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L)))
|
||||
.thenReturn(Map.of(7L, List.of(new SkillLabelDto("automation", "RECOMMENDED", "Automation"))));
|
||||
|
||||
ClawHubSkillListResponse response = service.listSkills(0, 25, null, true, null, Map.of());
|
||||
|
||||
assertThat(response.items().get(0).labels())
|
||||
.extracting(SkillLabelDto::slug)
|
||||
.containsExactly("automation");
|
||||
}
|
||||
|
||||
private static SkillSummaryResponse summary(Long id) {
|
||||
return new SkillSummaryResponse(
|
||||
id, "demo-skill", "Demo Skill", "A demo", "PUBLIC", "PUBLISHED",
|
||||
0L, 0, null, 0, "global", null, false, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.service.SkillLabelProjectionService;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
|
@ -34,6 +37,9 @@ class SkillSearchControllerTest {
|
|||
@MockBean
|
||||
private SkillSearchAppService skillSearchAppService;
|
||||
|
||||
@MockBean
|
||||
private SkillLabelProjectionService skillLabelProjectionService;
|
||||
|
||||
@Test
|
||||
void searchShouldUseUnifiedEnvelopeAndItemsField() throws Exception {
|
||||
when(skillSearchAppService.search(
|
||||
|
|
@ -143,4 +149,50 @@ class SkillSearchControllerTest {
|
|||
.andExpect(jsonPath("$.data.page").value(0))
|
||||
.andExpect(jsonPath("$.data.size").value(20));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchShouldOmitLabelsUnlessRequested() throws Exception {
|
||||
when(skillSearchAppService.search(
|
||||
eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any()))
|
||||
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20));
|
||||
|
||||
mockMvc.perform(get("/api/web/skills"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].slug").value("demo-skill"))
|
||||
.andExpect(jsonPath("$.data.items[0].labels").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchShouldReturnLabelsWhenRequested() throws Exception {
|
||||
when(skillSearchAppService.search(
|
||||
eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any()))
|
||||
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20));
|
||||
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L)))
|
||||
.thenReturn(Map.of(7L, List.of(new SkillLabelDto("automation", "TOPIC", "Automation"))));
|
||||
|
||||
mockMvc.perform(get("/api/web/skills").param("includeLabels", "true"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].labels[0].slug").value("automation"))
|
||||
.andExpect(jsonPath("$.data.items[0].labels[0].type").value("TOPIC"))
|
||||
.andExpect(jsonPath("$.data.items[0].labels[0].displayName").value("Automation"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchShouldReturnEmptyLabelArrayForSkillsWithoutLabels() throws Exception {
|
||||
when(skillSearchAppService.search(
|
||||
eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any()))
|
||||
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20));
|
||||
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L))).thenReturn(Map.of());
|
||||
|
||||
mockMvc.perform(get("/api/web/skills").param("includeLabels", "true"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].labels").isArray())
|
||||
.andExpect(jsonPath("$.data.items[0].labels").isEmpty());
|
||||
}
|
||||
|
||||
private static SkillSummaryResponse summary(Long id) {
|
||||
return new SkillSummaryResponse(
|
||||
id, "demo-skill", "Demo Skill", "A demo", "PUBLIC", "PUBLISHED",
|
||||
0L, 0, null, 0, "global", null, false, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.domain.label.LabelDefinition;
|
||||
import com.iflytek.skillhub.domain.label.LabelDefinitionService;
|
||||
import com.iflytek.skillhub.domain.label.LabelType;
|
||||
import com.iflytek.skillhub.domain.label.SkillLabel;
|
||||
import com.iflytek.skillhub.domain.label.SkillLabelService;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
class SkillLabelProjectionServiceTest {
|
||||
|
||||
private final SkillLabelService skillLabelService = mock(SkillLabelService.class);
|
||||
private final LabelDefinitionService labelDefinitionService = mock(LabelDefinitionService.class);
|
||||
private final LabelLocalizationService labelLocalizationService = new LabelLocalizationService();
|
||||
|
||||
private final SkillLabelProjectionService service = new SkillLabelProjectionService(
|
||||
skillLabelService, labelDefinitionService, labelLocalizationService);
|
||||
|
||||
@Test
|
||||
void labelsBySkillIds_groupsLabelsPerSkillInOneBatch() {
|
||||
LabelDefinition automation = definition(10L, "automation", LabelType.RECOMMENDED);
|
||||
LabelDefinition audited = definition(11L, "audited", LabelType.PRIVILEGED);
|
||||
|
||||
when(skillLabelService.listSkillLabelsBySkillIds(List.of(1L, 2L))).thenReturn(List.of(
|
||||
new SkillLabel(1L, 10L, "owner-1"),
|
||||
new SkillLabel(1L, 11L, "owner-1"),
|
||||
new SkillLabel(2L, 10L, "owner-2")
|
||||
));
|
||||
when(labelDefinitionService.listByIds(anyList())).thenReturn(List.of(automation, audited));
|
||||
when(labelDefinitionService.listTranslationsByLabelIds(anyList())).thenReturn(Map.of());
|
||||
|
||||
Map<Long, List<SkillLabelDto>> labels = service.labelsBySkillIds(List.of(1L, 2L));
|
||||
|
||||
// sorted by label type, then slug: PRIVILEGED before RECOMMENDED
|
||||
assertEquals(List.of("audited", "automation"), labels.get(1L).stream().map(SkillLabelDto::slug).toList());
|
||||
assertEquals(List.of("automation"), labels.get(2L).stream().map(SkillLabelDto::slug).toList());
|
||||
|
||||
// One query per lookup for the whole page, not per skill.
|
||||
verify(skillLabelService, times(1)).listSkillLabelsBySkillIds(anyList());
|
||||
verify(labelDefinitionService, times(1)).listByIds(anyList());
|
||||
verify(labelDefinitionService, times(1)).listTranslationsByLabelIds(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelsBySkillIds_skipsAssignmentsWithoutADefinition() {
|
||||
when(skillLabelService.listSkillLabelsBySkillIds(anyList()))
|
||||
.thenReturn(List.of(new SkillLabel(1L, 99L, "owner-1")));
|
||||
when(labelDefinitionService.listByIds(anyList())).thenReturn(List.of());
|
||||
when(labelDefinitionService.listTranslationsByLabelIds(anyList())).thenReturn(Map.of());
|
||||
|
||||
assertTrue(service.labelsBySkillIds(List.of(1L)).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelsBySkillIds_touchesNoRepositoryForAnEmptyPage() {
|
||||
assertTrue(service.labelsBySkillIds(List.of()).isEmpty());
|
||||
assertTrue(service.labelsBySkillIds(null).isEmpty());
|
||||
|
||||
verify(skillLabelService, never()).listSkillLabelsBySkillIds(any());
|
||||
}
|
||||
|
||||
private static LabelDefinition definition(Long id, String slug, LabelType type) {
|
||||
LabelDefinition definition = new LabelDefinition(slug, type, true, 0, "admin");
|
||||
ReflectionTestUtils.setField(definition, "id", id);
|
||||
return definition;
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,13 @@ public class SkillLabelService {
|
|||
return skillLabelRepository.findBySkillId(skillId);
|
||||
}
|
||||
|
||||
public List<SkillLabel> listSkillLabelsBySkillIds(List<Long> skillIds) {
|
||||
if (skillIds == null || skillIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return skillLabelRepository.findBySkillIdIn(skillIds);
|
||||
}
|
||||
|
||||
public List<SkillLabel> listByLabelId(Long labelId) {
|
||||
return skillLabelRepository.findByLabelId(labelId);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue