You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
482 lines
19 KiB
482 lines
19 KiB
package com.mydreamjob.skill;
|
|
|
|
import com.mydreamjob.profile.StudentProfile;
|
|
import com.mydreamjob.profile.StudentProfileRepository;
|
|
import com.mydreamjob.skill.domain.SkillAlias;
|
|
import com.mydreamjob.skill.dto.SkillCloudItem;
|
|
import com.mydreamjob.skill.dto.SkillCloudNode;
|
|
import com.mydreamjob.skill.dto.SkillCloudResponse;
|
|
import com.mydreamjob.skill.dto.SkillMatchResponse;
|
|
import com.mydreamjob.skill.repository.SkillAliasRepository;
|
|
import org.hibernate.boot.model.relational.Namespace;
|
|
import org.hibernate.boot.model.relational.Sequence;
|
|
import org.hibernate.mapping.Table;
|
|
import org.hibernate.tool.schema.spi.SchemaFilter;
|
|
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.extension.ExtendWith;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.boot.test.context.SpringBootTest;
|
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
|
import org.springframework.boot.test.mock.mockito.SpyBean;
|
|
import org.springframework.boot.test.system.CapturedOutput;
|
|
import org.springframework.boot.test.system.OutputCaptureExtension;
|
|
import org.springframework.data.redis.core.RedisTemplate;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.test.context.ActiveProfiles;
|
|
import org.springframework.test.context.TestPropertySource;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
import java.math.BigDecimal;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Optional;
|
|
import java.util.Set;
|
|
import java.util.stream.IntStream;
|
|
import java.util.stream.Stream;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.anyCollection;
|
|
import static org.mockito.Mockito.doReturn;
|
|
|
|
@SpringBootTest
|
|
@ExtendWith(OutputCaptureExtension.class)
|
|
@TestPropertySource(properties = {
|
|
"spring.datasource.url=jdbc:h2:mem:integrationdb_skill_match;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
|
|
"spring.jpa.hibernate.ddl-auto=validate",
|
|
"spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.skill.SkillMatchServiceTest$SkillMatchSchemaFilterProvider"
|
|
})
|
|
@ActiveProfiles("integration-test")
|
|
@Transactional
|
|
class SkillMatchServiceTest {
|
|
|
|
private static final Long USER_ID = 7001L;
|
|
|
|
@Autowired
|
|
private SkillMatchService service;
|
|
|
|
@SpyBean
|
|
private SkillCloudQueryService queryService;
|
|
|
|
@SpyBean
|
|
private StudentProfileRepository profileRepository;
|
|
|
|
@SpyBean
|
|
private SkillAliasRepository aliasRepository;
|
|
|
|
@Autowired
|
|
private JdbcTemplate jdbcTemplate;
|
|
|
|
@MockBean
|
|
private RedisTemplate<String, Object> redisTemplate;
|
|
|
|
@Test
|
|
void normalizerAppliesUnicodeRulesAndPreservesProgrammingSymbols() {
|
|
SkillNameNormalizer normalizer = new SkillNameNormalizer();
|
|
|
|
assertThat(normalizer.normalize(" Spring‐Boot. ")).isEqualTo("spring boot");
|
|
assertThat(normalizer.normalize(" JAVA\n\tSCRIPT ")).isEqualTo("java script");
|
|
assertThat(normalizer.normalize("Node.js")).isEqualTo("nodejs");
|
|
assertThat(normalizer.normalize("C++")).isEqualTo("c++");
|
|
assertThat(normalizer.normalize("C#")).isEqualTo("c#");
|
|
}
|
|
|
|
@Test
|
|
void usesOnlyTopTenWhenMoreThanTenSkillsExist() {
|
|
List<SkillCloudItem> items = IntStream.rangeClosed(1, 11)
|
|
.mapToObj(index -> item((long) index, "Skill " + index, "Skill " + index, "SKILL_" + index))
|
|
.toList();
|
|
stubCloud(cloud(items));
|
|
saveProfile(USER_ID, json(items.stream().map(SkillCloudItem::nameZh).toList()));
|
|
|
|
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
|
|
|
|
assertThat(result.totalCount()).isEqualTo(10);
|
|
assertThat(result.matchedCount()).isEqualTo(10);
|
|
assertThat(result.matchScore()).isEqualTo(100);
|
|
assertThat(result.matchedSkills())
|
|
.extracting(SkillCloudItem::skillId)
|
|
.containsExactlyElementsOf(IntStream.rangeClosed(1, 10).mapToLong(Long::valueOf).boxed().toList());
|
|
assertThat(result.missingSkills()).isEmpty();
|
|
}
|
|
|
|
@Test
|
|
void usesTenAsDenominatorWhenExactlyTenSkillsExist() {
|
|
List<SkillCloudItem> items = IntStream.rangeClosed(1, 10)
|
|
.mapToObj(index -> item((long) index, "Skill " + index, "Skill " + index, "SKILL_" + index))
|
|
.toList();
|
|
stubCloud(cloud(items));
|
|
saveProfile(USER_ID, json(items.stream().limit(5).map(SkillCloudItem::nameZh).toList()));
|
|
|
|
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
|
|
|
|
assertThat(result.totalCount()).isEqualTo(10);
|
|
assertThat(result.matchedCount()).isEqualTo(5);
|
|
assertThat(result.matchScore()).isEqualTo(50);
|
|
assertThat(result.missingSkills()).hasSize(5);
|
|
}
|
|
|
|
@Test
|
|
void usesActualSizeAsDenominatorAndRoundsOneThirdToThirtyThree() {
|
|
List<SkillCloudItem> items = List.of(
|
|
item(1L, "Java", "Java", "JAVA"),
|
|
item(2L, "Python", "Python", "PYTHON"),
|
|
item(3L, "SQL", "SQL", "SQL")
|
|
);
|
|
stubCloud(cloud(items));
|
|
saveProfile(USER_ID, json(List.of("Java")));
|
|
|
|
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
|
|
|
|
assertThat(result.totalCount()).isEqualTo(3);
|
|
assertThat(result.matchedCount()).isEqualTo(1);
|
|
assertThat(result.matchScore()).isEqualTo(33);
|
|
assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L);
|
|
assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(2L, 3L);
|
|
}
|
|
|
|
@Test
|
|
void returnsZeroAndEmptyListsWhenCloudIsEmpty() {
|
|
stubCloud(cloud(List.of()));
|
|
saveProfile(USER_ID, json(List.of("Java")));
|
|
|
|
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
|
|
|
|
assertThat(result.matchScore()).isZero();
|
|
assertThat(result.matchedCount()).isZero();
|
|
assertThat(result.totalCount()).isZero();
|
|
assertThat(result.matchedSkills()).isEmpty();
|
|
assertThat(result.missingSkills()).isEmpty();
|
|
}
|
|
|
|
@Test
|
|
void treatsMissingProfileAsNoSkills() {
|
|
stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA"))));
|
|
|
|
SkillMatchResponse result = service.match(9999L, 1L, 1L, null);
|
|
|
|
assertThat(result.matchScore()).isZero();
|
|
assertThat(result.matchedSkills()).isEmpty();
|
|
assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L);
|
|
}
|
|
|
|
@Test
|
|
void treatsBlankAndEmptyArrayAsNoSkills() {
|
|
stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA"), item(2L, "SQL", "SQL", "SQL"))));
|
|
doReturn(Optional.of(StudentProfile.builder()
|
|
.id(100L)
|
|
.userId(USER_ID)
|
|
.skillsJson(" ")
|
|
.build()))
|
|
.when(profileRepository).findByUserId(USER_ID);
|
|
|
|
SkillMatchResponse blank = service.match(USER_ID, 1L, 1L, null);
|
|
|
|
assertThat(blank.matchScore()).isZero();
|
|
assertThat(blank.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L);
|
|
|
|
doReturn(Optional.of(StudentProfile.builder()
|
|
.id(101L)
|
|
.userId(USER_ID)
|
|
.skillsJson("[]")
|
|
.build()))
|
|
.when(profileRepository).findByUserId(USER_ID);
|
|
|
|
SkillMatchResponse empty = service.match(USER_ID, 1L, 1L, null);
|
|
|
|
assertThat(empty.matchScore()).isZero();
|
|
assertThat(empty.matchedSkills()).isEmpty();
|
|
}
|
|
|
|
@Test
|
|
void treatsMalformedJsonAsNoSkillsWithoutLeakingRawJson(CapturedOutput output) {
|
|
String malformedJson = "{\"skills\":[\"Java\"]";
|
|
stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA"))));
|
|
doReturn(Optional.of(StudentProfile.builder()
|
|
.id(102L)
|
|
.userId(USER_ID)
|
|
.skillsJson(malformedJson)
|
|
.build()))
|
|
.when(profileRepository).findByUserId(USER_ID);
|
|
|
|
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
|
|
|
|
assertThat(result.matchScore()).isZero();
|
|
assertThat(result.matchedSkills()).isEmpty();
|
|
assertThat(output.getAll()).contains("Failed to parse student skills JSON");
|
|
assertThat(output.getAll()).doesNotContain(malformedJson);
|
|
}
|
|
|
|
@Test
|
|
void matchesNameZhNameEnSkillCodeAndAliasAndDeduplicatesStudentSkills() {
|
|
List<SkillCloudItem> items = List.of(
|
|
item(1L, "数据库", "Database Systems", "DATA_STORE_CLASS"),
|
|
item(2L, "中文技能", "database technology", "UNRELATED_CODE"),
|
|
item(3L, "代码技能", "Code English", "DATABASE_ENGINE")
|
|
);
|
|
stubCloud(cloud(items));
|
|
doReturn(List.of(alias(1L, "JVM Language", "jvm language")))
|
|
.when(aliasRepository).findAllBySkillIdInOrderByIdAsc(anyCollection());
|
|
saveProfile(USER_ID, json(List.of(
|
|
"jvm-language",
|
|
"JVM LANGUAGE",
|
|
"数据库",
|
|
"DATABASE TECHNOLOGY",
|
|
"database_engine"
|
|
)));
|
|
|
|
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
|
|
|
|
assertThat(result.matchScore()).isEqualTo(100);
|
|
assertThat(result.matchedCount()).isEqualTo(3);
|
|
assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L, 3L);
|
|
assertThat(result.missingSkills()).isEmpty();
|
|
}
|
|
|
|
@Test
|
|
void matchesOnlyNameZhForV36JavaWhenOtherIdentityFieldsDoNotMatch() {
|
|
jdbcTemplate.update("UPDATE skills SET code = ? WHERE id = ?", "JAVA_NAME_ZH_ISOLATED", 1L);
|
|
saveProfile(USER_ID, json(List.of("Java")));
|
|
|
|
SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L);
|
|
|
|
assertThat(result.totalCount()).isEqualTo(2);
|
|
assertThat(result.matchScore()).isEqualTo(50);
|
|
assertThat(result.matchedSkills())
|
|
.extracting(SkillCloudItem::skillId)
|
|
.containsExactly(1L);
|
|
assertThat(result.missingSkills())
|
|
.extracting(SkillCloudItem::skillId)
|
|
.containsExactly(2L);
|
|
}
|
|
|
|
@Test
|
|
void matchesOnlyInsertedAliasForV36JavaWhenAliasIsTheOnlyIdentityMatch() {
|
|
jdbcTemplate.update("""
|
|
INSERT INTO skill_aliases (id, skill_id, alias, normalized_alias)
|
|
VALUES (?, ?, ?, ?)
|
|
""", 10001L, 1L, "ONLY.ALIAS-JVM-XYZ", "only alias jvm xyz");
|
|
saveProfile(USER_ID, json(List.of("ONLY.ALIAS-JVM-XYZ")));
|
|
|
|
SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L);
|
|
|
|
assertThat(result.totalCount()).isEqualTo(2);
|
|
assertThat(result.matchScore()).isEqualTo(50);
|
|
assertThat(result.matchedSkills())
|
|
.extracting(SkillCloudItem::skillId)
|
|
.containsExactly(1L);
|
|
assertThat(result.missingSkills())
|
|
.extracting(SkillCloudItem::skillId)
|
|
.containsExactly(2L);
|
|
}
|
|
|
|
@Test
|
|
void doesNotApplyAliasOwnedByAnotherTopSkillToV36Java() {
|
|
jdbcTemplate.update("""
|
|
INSERT INTO skill_aliases (id, skill_id, alias, normalized_alias)
|
|
VALUES (?, ?, ?, ?)
|
|
""", 10002L, 2L, "ONLY.ALIAS-OWNER-SKILL-2", "only alias owner skill 2");
|
|
saveProfile(USER_ID, json(List.of("ONLY.ALIAS-OWNER-SKILL-2")));
|
|
|
|
SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L);
|
|
|
|
assertThat(result.totalCount()).isEqualTo(2);
|
|
assertThat(result.matchScore()).isEqualTo(50);
|
|
assertThat(result.matchedSkills())
|
|
.extracting(SkillCloudItem::skillId)
|
|
.containsExactly(2L);
|
|
assertThat(result.missingSkills())
|
|
.extracting(SkillCloudItem::skillId)
|
|
.containsExactly(1L);
|
|
}
|
|
|
|
@Test
|
|
void keepsMatchedAndMissingListsOrderedAndTheirUnionEqualToTopTen() {
|
|
List<SkillCloudItem> items = List.of(
|
|
item(1L, "Java", "Java", "JAVA"),
|
|
item(2L, "Python", "Python", "PYTHON"),
|
|
item(3L, "SQL", "SQL", "SQL")
|
|
);
|
|
stubCloud(cloud(items));
|
|
saveProfile(USER_ID, json(List.of("Python")));
|
|
|
|
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
|
|
List<Long> union = Stream.concat(result.matchedSkills().stream(), result.missingSkills().stream())
|
|
.map(SkillCloudItem::skillId)
|
|
.toList();
|
|
|
|
assertThat(result.matchScore()).isEqualTo(33);
|
|
assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(2L);
|
|
assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 3L);
|
|
assertThat(union).containsExactlyInAnyOrder(1L, 2L, 3L);
|
|
}
|
|
|
|
@Test
|
|
void returnsZeroWhenNoTopSkillMatches() {
|
|
List<SkillCloudItem> items = List.of(
|
|
item(1L, "Java", "Java", "JAVA"),
|
|
item(2L, "SQL", "SQL", "SQL")
|
|
);
|
|
stubCloud(cloud(items));
|
|
saveProfile(USER_ID, json(List.of("Marketing")));
|
|
|
|
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
|
|
|
|
assertThat(result.matchScore()).isZero();
|
|
assertThat(result.matchedSkills()).isEmpty();
|
|
assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L);
|
|
}
|
|
|
|
@Test
|
|
void returnsOneHundredAndNoMissingSkillsWhenAllTopSkillsMatch() {
|
|
List<SkillCloudItem> items = List.of(
|
|
item(1L, "Java", "Java", "JAVA"),
|
|
item(2L, "SQL", "SQL", "SQL")
|
|
);
|
|
stubCloud(cloud(items));
|
|
saveProfile(USER_ID, json(List.of("Java", "SQL")));
|
|
|
|
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
|
|
|
|
assertThat(result.matchScore()).isEqualTo(100);
|
|
assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L);
|
|
assertThat(result.missingSkills()).isEmpty();
|
|
}
|
|
|
|
@Test
|
|
void excludesCrossOccupationCategoryMismatchFromMatchSet() {
|
|
jdbcTemplate.update("""
|
|
INSERT INTO occupation_skills (
|
|
id, occupation_id, skill_id, category_id, importance,
|
|
importance_source_code, importance_element_id, importance_is_proxy,
|
|
relationship_type, source_version_id
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
10000L, 1L, 1L, 201L, new BigDecimal("5.00"),
|
|
"15-1254.00", "2.E.7.h", true, "ESSENTIAL", 2L);
|
|
saveProfile(USER_ID, json(List.of("Java")));
|
|
|
|
SkillCloudResponse cloud = queryService.query(1L, 1L, null);
|
|
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
|
|
List<Long> expectedTop = cloud.items().stream().limit(10).map(SkillCloudItem::skillId).toList();
|
|
List<Long> actualUnion = Stream.concat(result.matchedSkills().stream(), result.missingSkills().stream())
|
|
.map(SkillCloudItem::skillId)
|
|
.toList();
|
|
|
|
assertThat(result.matchedCount()).isZero();
|
|
assertThat(actualUnion).containsExactlyElementsOf(expectedTop);
|
|
assertThat(actualUnion).doesNotContain(1L);
|
|
}
|
|
|
|
private void stubCloud(SkillCloudResponse response) {
|
|
doReturn(response).when(queryService).query(any(), any(), any());
|
|
}
|
|
|
|
private StudentProfile saveProfile(Long userId, String skillsJson) {
|
|
jdbcTemplate.update("""
|
|
INSERT INTO users (id, phone, role, is_active, is_verified, created_at, updated_at)
|
|
VALUES (?, ?, 'STUDENT', TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
""", userId, String.valueOf(userId));
|
|
return profileRepository.saveAndFlush(StudentProfile.builder()
|
|
.userId(userId)
|
|
.skillsJson(skillsJson)
|
|
.build());
|
|
}
|
|
|
|
private SkillCloudResponse cloud(List<SkillCloudItem> items) {
|
|
return new SkillCloudResponse(
|
|
new SkillCloudNode(1L, "INTERNET", "互联网"),
|
|
new SkillCloudNode(1L, "FRONTEND_DEVELOPER", "前端开发工程师"),
|
|
null,
|
|
items.size(),
|
|
items
|
|
);
|
|
}
|
|
|
|
private SkillCloudItem item(Long id, String nameZh, String nameEn, String code) {
|
|
return new SkillCloudItem(
|
|
id.intValue(),
|
|
id,
|
|
code,
|
|
nameZh,
|
|
nameEn,
|
|
"O*NET",
|
|
"test-source",
|
|
1L,
|
|
"测试分类",
|
|
BigDecimal.TEN,
|
|
null,
|
|
null,
|
|
false
|
|
);
|
|
}
|
|
|
|
private SkillAlias alias(Long skillId, String alias, String normalizedAlias) {
|
|
return SkillAlias.builder()
|
|
.id(1L)
|
|
.skillId(skillId)
|
|
.alias(alias)
|
|
.normalizedAlias(normalizedAlias)
|
|
.build();
|
|
}
|
|
|
|
private String json(List<String> values) {
|
|
return values.stream()
|
|
.map(value -> "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"")
|
|
.collect(java.util.stream.Collectors.joining(",", "[", "]"));
|
|
}
|
|
|
|
public static class SkillMatchSchemaFilterProvider implements SchemaFilterProvider {
|
|
|
|
private static final Set<String> VALIDATED_TABLES = Set.of(
|
|
"skill_source_versions",
|
|
"skill_industries",
|
|
"skill_occupations",
|
|
"skill_categories",
|
|
"skills",
|
|
"skill_aliases",
|
|
"occupation_skills"
|
|
);
|
|
|
|
@Override
|
|
public SchemaFilter getCreateFilter() {
|
|
return SchemaFilter.ALL;
|
|
}
|
|
|
|
@Override
|
|
public SchemaFilter getDropFilter() {
|
|
return SchemaFilter.ALL;
|
|
}
|
|
|
|
@Override
|
|
public SchemaFilter getTruncatorFilter() {
|
|
return SchemaFilter.ALL;
|
|
}
|
|
|
|
@Override
|
|
public SchemaFilter getMigrateFilter() {
|
|
return SchemaFilter.ALL;
|
|
}
|
|
|
|
@Override
|
|
public SchemaFilter getValidateFilter() {
|
|
return new SchemaFilter() {
|
|
@Override
|
|
public boolean includeNamespace(Namespace namespace) {
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public boolean includeTable(Table table) {
|
|
return VALIDATED_TABLES.contains(table.getName().toLowerCase(Locale.ROOT));
|
|
}
|
|
|
|
@Override
|
|
public boolean includeSequence(Sequence sequence) {
|
|
return false;
|
|
}
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|