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; + } + } +}