package com.mydreamjob.privacy; 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.privacy.dto.PrivacySettingsUpdateRequest; 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.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.nio.charset.StandardCharsets; 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.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; 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.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * GET /api/v1/profile/me/privacy 与 PUT /api/v1/profile/me/privacy 契约集成测试。 * *

覆盖:STUDENT 成功、GET 无记录返回默认且不写库、角色 403、未登录 401、 * userId 参数注入无效、非法枚举/缺参 400、version 冲突 409/9201、敏感字段不泄露、OpenAPI 一致性。 */ @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") class PrivacySettingsControllerTest { @Autowired private MockMvc mockMvc; @Autowired private JwtTokenProvider jwtTokenProvider; @Autowired private UserRepository userRepository; @Autowired private StudentPrivacySettingsRepository privacyRepository; @MockBean private org.springframework.data.redis.core.RedisTemplate redisTemplate; private final ObjectMapper mapper = new ObjectMapper(); private static final AtomicLong PHONE = new AtomicLong(910_000_000L); @BeforeEach @SuppressWarnings("unchecked") void setUp() { org.springframework.data.redis.core.ValueOperations ops = mock(org.springframework.data.redis.core.ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(anyString())).thenReturn(false); // 不得 deleteAll;通过自增 phone 保证用户唯一。 } private long createUser(User.Role role) { User u = userRepository.save(User.builder() .phone(String.valueOf(PHONE.getAndIncrement())) .roles(Set.of(role)) .role(role) .active(true) .build()); return u.getId(); } private String studentToken(long userId) { return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } // ── GET:STUDENT 成功 + 无记录默认值 ────────────────────── @Test void getPrivacy_noRecord_returns200Default_andDoesNotWriteDb() throws Exception { long uid = createUser(User.Role.STUDENT); long countBefore = privacyRepository.count(); String body = mockMvc.perform(get("/profile/me/privacy") .header("Authorization", studentToken(uid))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.resumeVisibility").value("PRIVATE")) .andExpect(jsonPath("$.data.contactVisible").value(false)) .andExpect(jsonPath("$.data.version").value(0)) .andExpect(jsonPath("$.data.studentUserId").value(uid)) .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); JsonNode data = mapper.readTree(body).path("data"); assertFalse(data.hasNonNull("id"), "无记录时不应返回 id"); assertFalse(data.hasNonNull("createdAt"), "无记录时不应返回 createdAt"); assertEquals(privacyRepository.count(), countBefore, "查询不应写入 student_privacy_settings 记录"); } @Test void getPrivacy_afterPut_returnsStoredValues() throws Exception { long uid = createUser(User.Role.STUDENT); mockMvc.perform(put("/profile/me/privacy") .header("Authorization", studentToken(uid)) .contentType(MediaType.APPLICATION_JSON) .content("{\"resumeVisibility\":\"VERIFIED_COMPANIES\",\"contactVisible\":true}")) .andExpect(status().isOk()); mockMvc.perform(get("/profile/me/privacy") .header("Authorization", studentToken(uid))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.resumeVisibility").value("VERIFIED_COMPANIES")) .andExpect(jsonPath("$.data.contactVisible").value(true)); } // ── PUT:成功 + upsert 单行 ─────────────────────────────── @Test void putPrivacy_firstUpdate_returns200() throws Exception { long uid = createUser(User.Role.STUDENT); mockMvc.perform(put("/profile/me/privacy") .header("Authorization", studentToken(uid)) .contentType(MediaType.APPLICATION_JSON) .content("{\"resumeVisibility\":\"APPLIED_COMPANIES\",\"contactVisible\":true}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.resumeVisibility").value("APPLIED_COMPANIES")) .andExpect(jsonPath("$.data.contactVisible").value(true)); } @Test void putPrivacy_repeatedly_keepsSingleRow() throws Exception { long uid = createUser(User.Role.STUDENT); mockMvc.perform(put("/profile/me/privacy") .header("Authorization", studentToken(uid)) .contentType(MediaType.APPLICATION_JSON) .content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}")) .andExpect(status().isOk()); mockMvc.perform(put("/profile/me/privacy") .header("Authorization", studentToken(uid)) .contentType(MediaType.APPLICATION_JSON) .content("{\"resumeVisibility\":\"VERIFIED_COMPANIES\",\"contactVisible\":true}")) .andExpect(status().isOk()); long count = privacyRepository.findByStudentUserId(uid).map(s -> 1L).orElse(0L); assertEquals(1L, count, "同一学生必须始终只有一行"); } @Test void putPrivacy_versionConflict_returns409_9201() throws Exception { long uid = createUser(User.Role.STUDENT); // 先创建,当前 version=0 mockMvc.perform(put("/profile/me/privacy") .header("Authorization", studentToken(uid)) .contentType(MediaType.APPLICATION_JSON) .content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}")) .andExpect(status().isOk()); // 传不匹配的 version=5 → 409/9201 mockMvc.perform(put("/profile/me/privacy") .header("Authorization", studentToken(uid)) .contentType(MediaType.APPLICATION_JSON) .content("{\"resumeVisibility\":\"VERIFIED_COMPANIES\",\"contactVisible\":true,\"version\":5}")) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(9201)); } // ── 校验失败 ───────────────────────────────────────────── @Test void putPrivacy_invalidEnum_returns400() throws Exception { long uid = createUser(User.Role.STUDENT); mockMvc.perform(put("/profile/me/privacy") .header("Authorization", studentToken(uid)) .contentType(MediaType.APPLICATION_JSON) .content("{\"resumeVisibility\":\"INVALID_VALUE\",\"contactVisible\":true}")) .andExpect(status().isBadRequest()); } @Test void putPrivacy_missingFields_returns400() throws Exception { long uid = createUser(User.Role.STUDENT); mockMvc.perform(put("/profile/me/privacy") .header("Authorization", studentToken(uid)) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isBadRequest()); } // ── 认证 / 角色 ────────────────────────────────────────── @Test void getPrivacy_noToken_returns401() throws Exception { mockMvc.perform(get("/profile/me/privacy")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.code").value(401)); } @Test void getPrivacy_invalidToken_returns401() throws Exception { mockMvc.perform(get("/profile/me/privacy") .header("Authorization", "Bearer not-a-valid-token")) .andExpect(status().isUnauthorized()); } @Test void getPrivacy_companyToken_returns403_2002() throws Exception { long uid = createUser(User.Role.COMPANY); mockMvc.perform(get("/profile/me/privacy") .header("Authorization", studentToken(uid))) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(2002)); } @Test void getPrivacy_adminToken_returns403_2002() throws Exception { long uid = createUser(User.Role.ADMIN); mockMvc.perform(get("/profile/me/privacy") .header("Authorization", studentToken(uid))) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(2002)); } @Test void putPrivacy_companyToken_returns403_2002() throws Exception { long uid = createUser(User.Role.COMPANY); mockMvc.perform(put("/profile/me/privacy") .header("Authorization", studentToken(uid)) .contentType(MediaType.APPLICATION_JSON) .content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}")) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(2002)); } // ── 参数注入防护 ───────────────────────────────────────── @Test void putPrivacy_ignoresUserIdParam_usesJwtIdentity() throws Exception { long uid = createUser(User.Role.STUDENT); mockMvc.perform(put("/profile/me/privacy") .header("Authorization", studentToken(uid)) .contentType(MediaType.APPLICATION_JSON) .param("userId", String.valueOf(uid * 999 + 1)) .content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.studentUserId").value(uid)); } // ── 敏感字段不泄露(审计不写敏感值已在 ServiceTest 覆盖) ── @Test void getPrivacy_okResponse_forbidsSensitiveFields() throws Exception { long uid = createUser(User.Role.STUDENT); String body = mockMvc.perform(get("/profile/me/privacy") .header("Authorization", studentToken(uid))) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); String normalized = body.toLowerCase(); assertFalse(normalized.contains("password")); assertFalse(normalized.contains("phone")); assertFalse(normalized.contains("email")); assertFalse(normalized.contains("jwt")); assertFalse(normalized.contains("resumeurl")); } // ── OpenAPI 一致性 ─────────────────────────────────────── @Test void openApi_shouldDocumentPrivacyContract() throws Exception { String json = mockMvc.perform(get("/v3/api-docs")) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); JsonNode root = mapper.readTree(json); JsonNode get = root.path("paths").path("/profile/me/privacy").path("get"); JsonNode put = root.path("paths").path("/profile/me/privacy").path("put"); assertThat(get.isMissingNode()).isFalse(); assertThat(put.isMissingNode()).isFalse(); // 引用明确 DTO,而非 Map assertTrue(get.toString().contains("PrivacySettingsResponse")); assertTrue(put.toString().contains("PrivacySettingsResponse")); // 安全要求 assertTrue(get.path("security").toString().contains("bearerAuth")); assertTrue(put.path("security").toString().contains("bearerAuth")); // 无请求参数 assertThat(put.path("parameters").isMissingNode() || (put.path("parameters").isArray() && put.path("parameters").isEmpty())).isTrue(); // 响应声明 assertThat(get.path("responses").has("200")).isTrue(); assertThat(get.path("responses").has("401")).isTrue(); assertThat(get.path("responses").has("403")).isTrue(); assertThat(put.path("responses").has("400")).isTrue(); assertThat(put.path("responses").has("409")).isTrue(); // 请求体 schema 引用 DTO assertThat(put.path("requestBody").toString().contains("PrivacySettingsUpdateRequest")).isTrue(); // 无敏感字段 String responseSchema = get.path("responses").path("200").toString().toLowerCase(); assertThat(responseSchema.contains("\"password\"")).isFalse(); assertThat(responseSchema.contains("\"passwordhash\"")).isFalse(); assertThat(responseSchema.contains("\"token\"")).isFalse(); } }