package com.mydreamjob.ability; 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 org.junit.jupiter.api.AfterEach; 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.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import static org.junit.jupiter.api.Assertions.*; 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.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") class AbilityControllerTest { private static final AtomicLong PHONE = new AtomicLong(6000); @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Autowired private JwtTokenProvider jwtTokenProvider; @Autowired private UserRepository userRepository; @MockBean private RedisTemplate redisTemplate; private Long studentUserId; private Long companyUserId; private Long adminUserId; @BeforeEach @SuppressWarnings("unchecked") void setUp() { ValueOperations ops = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(any())).thenReturn(false); studentUserId = createUser(User.Role.STUDENT); companyUserId = createUser(User.Role.COMPANY); adminUserId = createUser(User.Role.ADMIN); } @AfterEach void tearDown() { userRepository.deleteById(adminUserId); userRepository.deleteById(companyUserId); userRepository.deleteById(studentUserId); } private Long createUser(User.Role role) { User u = userRepository.save(User.builder() .phone("17" + String.format("%08d", PHONE.incrementAndGet())).nickname("u") .role(role).roles(Set.of(role)).active(true).verified(true).build()); return u.getId(); } private String token(Long userId, User.Role role) { return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } @Test void anonymous_create_returns401() throws Exception { mockMvc.perform(post("/abilities/attempts").contentType(MediaType.APPLICATION_JSON) .content("{\"typeCode\":\"WORKPLACE_ABILITY\"}")).andExpect(status().isUnauthorized()); } @Test void studentListTypes_returns200() throws Exception { mockMvc.perform(get("/abilities/types").header("Authorization", token(studentUserId, User.Role.STUDENT))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.[0].typeCode").value("WORKPLACE_ABILITY")); } @Test void companyAndAdmin_create_returns403() throws Exception { mockMvc.perform(post("/abilities/attempts").header("Authorization", token(companyUserId, User.Role.COMPANY)) .contentType(MediaType.APPLICATION_JSON).content("{\"typeCode\":\"WORKPLACE_ABILITY\"}")) .andExpect(status().isForbidden()); mockMvc.perform(post("/abilities/attempts").header("Authorization", token(adminUserId, User.Role.ADMIN)) .contentType(MediaType.APPLICATION_JSON).content("{\"typeCode\":\"WORKPLACE_ABILITY\"}")) .andExpect(status().isForbidden()); } @Test void fullStudentFlow_createSaveSubmitResult_repeatIdempotent_noSensitiveLeak() throws Exception { String tk = token(studentUserId, User.Role.STUDENT); JsonNode create = objectMapper.readTree(mockMvc.perform(post("/abilities/attempts") .header("Authorization", tk).contentType(MediaType.APPLICATION_JSON) .content("{\"typeCode\":\"WORKPLACE_ABILITY\",\"idempotencyKey\":\"http-key-1\"}")) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString()); long attemptId = create.path("data").path("attemptId").asLong(); assertEquals(24, create.path("data").path("questions").size()); List> answers = new ArrayList<>(); for (JsonNode q : create.path("data").path("questions")) { answers.add(Map.of("attemptQuestionId", q.path("attemptQuestionId").asLong(), "selectedOptionId", "A")); } String body = objectMapper.writeValueAsString(Map.of("answers", answers)); mockMvc.perform(patch("/abilities/attempts/{id}/answers", attemptId) .header("Authorization", tk).contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.answeredCount").value(24)); JsonNode submitted = objectMapper.readTree(mockMvc.perform(post("/abilities/attempts/{id}/submit", attemptId) .header("Authorization", tk)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("COMPLETED")) .andReturn().getResponse().getContentAsString()); // 结果脱敏:完整 result JSON 不得包含题库内部字段 String submitedRaw = submitted.toString(); assertFalse(submitedRaw.contains("studentUserId")); assertFalse(submitedRaw.contains("phone")); assertFalse(submitedRaw.contains("email")); assertFalse(submitedRaw.contains("answerKey"), "result must not contain answerKey"); assertFalse(submitedRaw.contains("scoringRule"), "result must not contain scoringRule"); assertFalse(submitedRaw.contains("questionCode"), "result must not contain questionCode"); assertFalse(submitedRaw.contains("competencyTag"), "result must not contain competencyTag"); assertFalse(submitedRaw.contains("source"), "result must not contain source"); assertFalse(submitedRaw.contains("difficulty"), "result must not contain difficulty"); assertFalse(submitedRaw.contains("discrimination"), "result must not contain discrimination"); assertTrue(submitedRaw.contains("disclaimer")); assertEquals(4, submitted.path("data").path("dimensionScores").size()); // 重复提交幂等:同一综合分 JsonNode resub = objectMapper.readTree(mockMvc.perform(post("/abilities/attempts/{id}/submit", attemptId) .header("Authorization", tk)).andExpect(status().isOk()) .andReturn().getResponse().getContentAsString()); assertEquals(submitted.path("data").path("overallScore").asText(), resub.path("data").path("overallScore").asText()); // GET result 200 mockMvc.perform(get("/abilities/attempts/{id}/result", attemptId).header("Authorization", tk)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.validityStatus").value("VALID")); } }