Browse Source

test: add skill cloud end-to-end regression

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
7873a42dc8
  1. 451
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java

451
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.
*
* <p>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.</p>
*/
@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<String, Object> redisTemplate;
@BeforeEach
@SuppressWarnings("unchecked")
void setUpRedisMock() {
ValueOperations<String, Object> 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<String, Long> countsBefore = taxonomyAndProfileCounts();
JsonNode cloud = data(getOkBody(
WORD_CLOUD_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
List<JsonNode> allSkills = nodes(cloud.path("items"));
assertThat(cloud.path("total").asInt()).isEqualTo(16);
assertThat(allSkills).hasSize(16);
assertRanksAreContinuous(allSkills);
assertHeatOrder(allSkills);
List<JsonNode> 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<JsonNode> 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<JsonNode> expectedTop10) {
List<Long> expectedIds = ids(expectedTop10);
List<Long> matchedIds = ids(match.path("matchedSkills"));
List<Long> 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<Long> expectedMatched = expectedIds.stream().filter(matchedIds::contains).toList();
List<Long> expectedMissing = expectedIds.stream().filter(id -> !matchedIds.contains(id)).toList();
assertThat(matchedIds).containsExactlyElementsOf(expectedMatched);
assertThat(missingIds).containsExactlyElementsOf(expectedMissing);
List<Long> 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<JsonNode> items) {
for (int index = 0; index < items.size(); index++) {
assertThat(items.get(index).path("rank").asInt()).isEqualTo(index + 1);
}
}
private void assertHeatOrder(List<JsonNode> 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<JsonNode> nodes(JsonNode array) {
List<JsonNode> nodes = new ArrayList<>();
array.forEach(nodes::add);
return nodes;
}
private List<Long> ids(JsonNode array) {
return ids(nodes(array));
}
private List<Long> ids(List<JsonNode> 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<String, Long> taxonomyAndProfileCounts() {
Map<String, Long> 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;
}
}
}
Loading…
Cancel
Save