You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

162 lines
7.7 KiB

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<String, Object> redisTemplate;
private Long studentUserId;
private Long companyUserId;
private Long adminUserId;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> 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<Map<String, Object>> 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"));
}
}