From 7873a42dc8b013eea12aa2225d34b836e86cb6e1 Mon Sep 17 00:00:00 2001
From: pwklppg <123456@.com>
Date: Tue, 15 Sep 2026 11:17:13 +0800
Subject: [PATCH] test: add skill cloud end-to-end regression
---
.../SkillCloudEndToEndIntegrationTest.java | 451 ++++++++++++++++++
1 file changed, 451 insertions(+)
create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java
new file mode 100644
index 0000000..3399e5b
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java
@@ -0,0 +1,451 @@
+package com.mydreamjob.skill;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.mydreamjob.auth.JwtTokenProvider;
+import com.mydreamjob.auth.User;
+import com.mydreamjob.auth.UserRepository;
+import com.mydreamjob.profile.StudentProfile;
+import com.mydreamjob.profile.StudentProfileRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * T9: real HTTP -> Security -> Controller -> Service -> Repository -> Flyway/H2 regression.
+ *
+ *
The test deliberately uses the real V36 data and real persisted student profile. It does
+ * not mock the skill services, so it can catch inconsistent TOP10 derivation, accidental writes,
+ * and response-layer privacy leaks.
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("integration-test")
+@TestPropertySource(properties = {
+ "spring.datasource.url=jdbc:h2:mem:skill_cloud_t9_e2e;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
+ "spring.jpa.show-sql=false"
+})
+@org.springframework.transaction.annotation.Transactional
+class SkillCloudEndToEndIntegrationTest {
+
+ private static final String WORD_CLOUD_PATH = "/skills/word-cloud";
+ private static final String MATCH_PATH = "/skills/match";
+ private static final String LEARNING_PATH = "/skills/learning-path";
+
+ private static final long BACKEND_INDUSTRY_ID = 1L;
+ private static final long BACKEND_OCCUPATION_ID = 2L;
+ private static final long THREE_SKILL_INDUSTRY_ID = 3L;
+ private static final long THREE_SKILL_OCCUPATION_ID = 9L;
+ private static final long THREE_SKILL_CATEGORY_ID = 903L;
+ private static final long EMPTY_CATEGORY_ID = 999_001L;
+
+ private static final AtomicLong PHONE = new AtomicLong(940_000_000L);
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @Autowired
+ private JwtTokenProvider jwtTokenProvider;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private StudentProfileRepository studentProfileRepository;
+
+ @MockBean
+ private RedisTemplate redisTemplate;
+
+ @BeforeEach
+ @SuppressWarnings("unchecked")
+ void setUpRedisMock() {
+ ValueOperations operations = mock(ValueOperations.class);
+ when(redisTemplate.opsForValue()).thenReturn(operations);
+ when(redisTemplate.hasKey(any())).thenReturn(false);
+ }
+
+ @Test
+ void threeEndpointsShareTop10AndDoNotWriteForBackendDeveloper() throws Exception {
+ StudentFixture student = createStudent("[\"Django\",\"Git\",\"Java\"]");
+ Map countsBefore = taxonomyAndProfileCounts();
+
+ JsonNode cloud = data(getOkBody(
+ WORD_CLOUD_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
+ List allSkills = nodes(cloud.path("items"));
+ assertThat(cloud.path("total").asInt()).isEqualTo(16);
+ assertThat(allSkills).hasSize(16);
+ assertRanksAreContinuous(allSkills);
+ assertHeatOrder(allSkills);
+
+ List expectedTop10 = allSkills.subList(0, Math.min(10, allSkills.size()));
+
+ JsonNode match = data(getOkBody(
+ MATCH_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
+ assertMatchReusesTop10(match, expectedTop10);
+
+ JsonNode learningPath = data(getOkBody(
+ LEARNING_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
+ assertLearningPathFollowsMissingSkills(learningPath, match.path("missingSkills"));
+
+ JsonNode cloudAgain = data(getOkBody(
+ WORD_CLOUD_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
+ JsonNode matchAgain = data(getOkBody(
+ MATCH_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
+ JsonNode learningAgain = data(getOkBody(
+ LEARNING_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
+
+ assertThat(cloudAgain).isEqualTo(cloud);
+ assertThat(matchAgain).isEqualTo(match);
+ assertThat(learningAgain).isEqualTo(learningPath);
+ assertThat(taxonomyAndProfileCounts()).isEqualTo(countsBefore);
+ }
+
+ @Test
+ void matchScoreUsesActualTopSizeAndRoundsToOneThird() throws Exception {
+ StudentFixture student = createStudent("[\"Articulate Storyline\"]");
+
+ JsonNode cloud = data(getOkBody(
+ WORD_CLOUD_PATH,
+ THREE_SKILL_INDUSTRY_ID,
+ THREE_SKILL_OCCUPATION_ID,
+ THREE_SKILL_CATEGORY_ID,
+ student.authorization(),
+ null));
+ List skills = nodes(cloud.path("items"));
+ assertThat(cloud.path("total").asInt()).isEqualTo(3);
+ assertThat(skills).hasSize(3);
+ assertHeatOrder(skills);
+ JsonNode lastHeat = skills.get(skills.size() - 1).path("heat");
+ assertThat(lastHeat.isMissingNode() || lastHeat.isNull()).isTrue();
+
+ JsonNode match = data(getOkBody(
+ MATCH_PATH,
+ THREE_SKILL_INDUSTRY_ID,
+ THREE_SKILL_OCCUPATION_ID,
+ THREE_SKILL_CATEGORY_ID,
+ student.authorization(),
+ null));
+ assertThat(match.path("totalCount").asInt()).isEqualTo(3);
+ assertThat(match.path("matchedCount").asInt()).isEqualTo(1);
+ assertThat(match.path("matchScore").asInt()).isEqualTo(33);
+ assertThat(match.path("matchedSkills")).hasSize(1);
+ assertThat(match.path("missingSkills")).hasSize(2);
+ assertThat(ids(match.path("matchedSkills")))
+ .containsExactly(skills.get(0).path("skillId").asLong());
+
+ JsonNode learningPath = data(getOkBody(
+ LEARNING_PATH,
+ THREE_SKILL_INDUSTRY_ID,
+ THREE_SKILL_OCCUPATION_ID,
+ THREE_SKILL_CATEGORY_ID,
+ student.authorization(),
+ null));
+ assertLearningPathFollowsMissingSkills(learningPath, match.path("missingSkills"));
+ }
+
+ @Test
+ void emptyCategoryReturnsEmptyResponsesAtPersistenceBoundary() throws Exception {
+ StudentFixture student = createStudent("[\"Java\"]");
+ jdbcTemplate.update(
+ "INSERT INTO skill_categories "
+ + "(id, occupation_id, code, name_zh, suggestion_template_code, sort_order, created_at, updated_at) "
+ + "VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
+ EMPTY_CATEGORY_ID,
+ BACKEND_OCCUPATION_ID,
+ "T9_EMPTY_CATEGORY",
+ "T9 空分类",
+ "GENERAL",
+ 99);
+
+ JsonNode cloud = data(getOkBody(
+ WORD_CLOUD_PATH,
+ BACKEND_INDUSTRY_ID,
+ BACKEND_OCCUPATION_ID,
+ EMPTY_CATEGORY_ID,
+ student.authorization(),
+ null));
+ assertThat(cloud.path("total").asInt()).isZero();
+ assertThat(cloud.path("items")).isEmpty();
+
+ JsonNode match = data(getOkBody(
+ MATCH_PATH,
+ BACKEND_INDUSTRY_ID,
+ BACKEND_OCCUPATION_ID,
+ EMPTY_CATEGORY_ID,
+ student.authorization(),
+ null));
+ assertThat(match.path("matchScore").asInt()).isZero();
+ assertThat(match.path("matchedCount").asInt()).isZero();
+ assertThat(match.path("totalCount").asInt()).isZero();
+ assertThat(match.path("matchedSkills")).isEmpty();
+ assertThat(match.path("missingSkills")).isEmpty();
+
+ JsonNode learningPath = data(getOkBody(
+ LEARNING_PATH,
+ BACKEND_INDUSTRY_ID,
+ BACKEND_OCCUPATION_ID,
+ EMPTY_CATEGORY_ID,
+ student.authorization(),
+ null));
+ assertThat(learningPath.path("total").asInt()).isZero();
+ assertThat(learningPath.path("items")).isEmpty();
+ }
+
+ @Test
+ void identityQueryParametersCannotOverrideAuthenticatedStudent() throws Exception {
+ StudentFixture student = createStudent("[\"Java\"]");
+ User company = createUser(User.Role.COMPANY);
+
+ for (String path : List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH)) {
+ JsonNode authenticated = data(getOkBody(
+ path, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
+ JsonNode withOverrides = data(getOkBody(
+ path, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), company.getId()));
+
+ assertThat(withOverrides).isEqualTo(authenticated);
+ }
+ }
+
+ @Test
+ void responsesDoNotLeakPrivateProfileDataOrInternalErrors() throws Exception {
+ StudentFixture student = createStudent("[\"Java\"]");
+
+ for (String path : List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH)) {
+ String body = getOkBody(
+ path, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null);
+ assertNoPrivateData(body, student);
+ }
+
+ String errorBody = mockMvc.perform(get(WORD_CLOUD_PATH)
+ .param("industryId", String.valueOf(BACKEND_INDUSTRY_ID))
+ .param("occupationId", String.valueOf(BACKEND_OCCUPATION_ID))
+ .param("categoryId", "999999")
+ .header("Authorization", student.authorization()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").isNumber())
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+
+ assertThat(errorBody.toLowerCase(Locale.ROOT))
+ .doesNotContain("exception")
+ .doesNotContain("org.springframework")
+ .doesNotContain("java.sql")
+ .doesNotContain("stacktrace")
+ .doesNotContain("\tat ");
+ }
+
+ private void assertMatchReusesTop10(JsonNode match, List expectedTop10) {
+ List expectedIds = ids(expectedTop10);
+ List matchedIds = ids(match.path("matchedSkills"));
+ List missingIds = ids(match.path("missingSkills"));
+
+ assertThat(match.path("totalCount").asInt()).isEqualTo(expectedTop10.size());
+ assertThat(match.path("matchedCount").asInt()).isEqualTo(matchedIds.size());
+ assertThat(matchedIds).allMatch(expectedIds::contains);
+ assertThat(expectedIds).containsAll(matchedIds);
+
+ List expectedMatched = expectedIds.stream().filter(matchedIds::contains).toList();
+ List expectedMissing = expectedIds.stream().filter(id -> !matchedIds.contains(id)).toList();
+ assertThat(matchedIds).containsExactlyElementsOf(expectedMatched);
+ assertThat(missingIds).containsExactlyElementsOf(expectedMissing);
+
+ List union = new ArrayList<>(matchedIds);
+ union.addAll(missingIds);
+ assertThat(union).containsExactlyInAnyOrderElementsOf(expectedIds);
+ int expectedScore = expectedTop10.isEmpty()
+ ? 0
+ : (int) Math.round(matchedIds.size() * 100.0 / expectedTop10.size());
+ assertThat(match.path("matchScore").asInt()).isEqualTo(expectedScore);
+ }
+
+ private void assertLearningPathFollowsMissingSkills(JsonNode learningPath, JsonNode missingSkills) {
+ assertThat(learningPath.path("total").asInt()).isEqualTo(missingSkills.size());
+ assertThat(learningPath.path("items")).hasSize(missingSkills.size());
+ for (int index = 0; index < missingSkills.size(); index++) {
+ JsonNode item = learningPath.path("items").get(index);
+ JsonNode missing = missingSkills.get(index);
+ assertThat(item.path("priority").asInt()).isEqualTo(index + 1);
+ assertThat(item.path("skillId").asLong()).isEqualTo(missing.path("skillId").asLong());
+ assertThat(item.path("skillName").asText()).isEqualTo(missing.path("nameZh").asText());
+ assertThat(item.path("suggestion").asText()).isNotBlank();
+ }
+ }
+
+ private void assertRanksAreContinuous(List items) {
+ for (int index = 0; index < items.size(); index++) {
+ assertThat(items.get(index).path("rank").asInt()).isEqualTo(index + 1);
+ }
+ }
+
+ private void assertHeatOrder(List items) {
+ boolean seenNullHeat = false;
+ BigDecimal previousHeat = null;
+ for (JsonNode item : items) {
+ if (item.path("heat").isNull()) {
+ seenNullHeat = true;
+ continue;
+ }
+ assertThat(seenNullHeat).isFalse();
+ BigDecimal currentHeat = item.path("heat").decimalValue();
+ if (previousHeat != null) {
+ assertThat(currentHeat).isLessThanOrEqualTo(previousHeat);
+ }
+ previousHeat = currentHeat;
+ }
+ }
+
+ private void assertNoPrivateData(String body, StudentFixture student) {
+ assertThat(body).doesNotContain("skills_json");
+ assertThat(body).doesNotContain(student.phone());
+ assertThat(body).doesNotContain(student.email());
+ assertThat(body).doesNotContain(student.resumeUrl());
+ assertThat(body).doesNotContain(student.resumeOriginalName());
+ assertThat(body).doesNotContain(student.accessToken());
+ }
+
+ private String getOkBody(String path,
+ long industryId,
+ long occupationId,
+ Long categoryId,
+ String authorization,
+ Long overrideIdentity) throws Exception {
+ var request = get(path)
+ .param("industryId", String.valueOf(industryId))
+ .param("occupationId", String.valueOf(occupationId))
+ .header("Authorization", authorization);
+ if (categoryId != null) {
+ request.param("categoryId", String.valueOf(categoryId));
+ }
+ if (overrideIdentity != null) {
+ request.param("userId", String.valueOf(overrideIdentity));
+ request.param("studentId", String.valueOf(overrideIdentity));
+ }
+
+ return mockMvc.perform(request)
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ }
+
+ private JsonNode data(String body) throws Exception {
+ return objectMapper.readTree(body).path("data");
+ }
+
+ private List nodes(JsonNode array) {
+ List nodes = new ArrayList<>();
+ array.forEach(nodes::add);
+ return nodes;
+ }
+
+ private List ids(JsonNode array) {
+ return ids(nodes(array));
+ }
+
+ private List ids(List items) {
+ return items.stream().map(node -> node.path("skillId").asLong()).toList();
+ }
+
+ private StudentFixture createStudent(String skillsJson) {
+ long phoneNumber = PHONE.getAndIncrement();
+ String phone = String.valueOf(phoneNumber);
+ String email = "private.student." + phoneNumber + "@example.com";
+ String resumeUrl = "https://private.example/" + phoneNumber + "-resume.pdf";
+ String resumeOriginalName = "private-" + phoneNumber + "-resume.pdf";
+
+ User user = userRepository.saveAndFlush(User.builder()
+ .phone(phone)
+ .email(email)
+ .role(User.Role.STUDENT)
+ .roles(Set.of(User.Role.STUDENT))
+ .active(true)
+ .verified(true)
+ .build());
+ studentProfileRepository.saveAndFlush(StudentProfile.builder()
+ .userId(user.getId())
+ .realName("sensitive-real-name")
+ .bio("raw-resume-sentinel")
+ .resumeUrl(resumeUrl)
+ .resumeOriginalName(resumeOriginalName)
+ .skillsJson(skillsJson)
+ .build());
+
+ String accessToken = jwtTokenProvider.generateAccessToken(
+ user.getId(),
+ phone,
+ Map.of("roles", "STUDENT"));
+ return new StudentFixture(user.getId(), phone, email, resumeUrl, resumeOriginalName, accessToken);
+ }
+
+ private User createUser(User.Role role) {
+ long phoneNumber = PHONE.getAndIncrement();
+ return userRepository.saveAndFlush(User.builder()
+ .phone(String.valueOf(phoneNumber))
+ .role(role)
+ .roles(Set.of(role))
+ .active(true)
+ .verified(true)
+ .build());
+ }
+
+ private Map taxonomyAndProfileCounts() {
+ Map counts = new LinkedHashMap<>();
+ for (String table : List.of(
+ "skill_source_versions",
+ "skill_industries",
+ "skill_occupations",
+ "skill_categories",
+ "skills",
+ "skill_aliases",
+ "occupation_skills",
+ "student_profiles")) {
+ Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table, Long.class);
+ counts.put(table, count);
+ }
+ return counts;
+ }
+
+ private record StudentFixture(
+ Long userId,
+ String phone,
+ String email,
+ String resumeUrl,
+ String resumeOriginalName,
+ String accessToken
+ ) {
+ String authorization() {
+ return "Bearer " + accessToken;
+ }
+ }
+}