1 changed files with 451 additions and 0 deletions
@ -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…
Reference in new issue