package com.mydreamjob.job; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.auth.AuthSessionService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; 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.http.MediaType; 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.time.Instant; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 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; /** * salaryMonths 通过**真实 Controller 的 HTTP 请求绑定**(MockMvc 全栈,非手工构造 Java DTO)验证: * 仅接受 JSON 整数 1—24,拒绝小数/字符串/布尔/数组/对象/超 int 范围;失败的写请求不产生任何部分更新。 */ @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") @TestPropertySource(properties = "spring.datasource.url=jdbc:h2:mem:salary_months_http;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") class JobSalaryMonthsHttpBindingTest { private static final AtomicInteger SEQ = new AtomicInteger(); private static final String PHONE_PREFIX = "1990077"; @MockBean private RedisTemplate redis; @Autowired private MockMvc mvc; @Autowired private JdbcTemplate jdbc; @Autowired private UserRepository users; @Autowired private CompanyRepository companies; @Autowired private AuthSessionService sessions; @Autowired private ObjectMapper mapper; private long userId; private long companyId; private String token; @BeforeEach void setUp() { User owner = users.saveAndFlush(User.builder() .phone(PHONE_PREFIX + String.format("%04d", SEQ.incrementAndGet())) .role(User.Role.COMPANY).roles(new HashSet<>(Set.of(User.Role.COMPANY))) .active(true).verified(true).build()); userId = owner.getId(); token = sessions.createSession(owner, Instant.now(), new AuditContext()).accessToken(); companyId = companies.saveAndFlush(Company.builder() .ownerUserId(userId).name("月数绑定企业").verified(true).build()).getId(); } @AfterEach void tearDown() { jdbc.update("delete from jobs where company_id = ?", companyId); jdbc.update("delete from companies where id = ?", companyId); jdbc.update("delete from auth_tokens where user_id = ?", userId); jdbc.update("delete from user_roles where user_id = ?", userId); jdbc.update("delete from users where id = ?", userId); } // ── 合法整数 ──────────────────────────────────────────────── @Test void duplicateBenefitsOverRawTwentyAreAcceptedAndNormalized() throws Exception { var payload = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(createBody("福利去重", "12")); var benefits = payload.putArray("benefits"); for (int i = 0; i < 21; i++) benefits.add(" A "); String body = mvc.perform(post("/jobs").header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(payload))) .andExpect(status().isOk()).andExpect(jsonPath("$.data.benefits.length()").value(1)) .andExpect(jsonPath("$.data.benefits[0]").value("A")) .andReturn().getResponse().getContentAsString(); long id = mapper.readTree(body).path("data").path("id").asLong(); assertEquals("[\"A\"]", jdbc.queryForObject("SELECT benefits_json FROM jobs WHERE id=?", String.class, id)); } @Test void benefitsItemLengthAppliesAfterTrimOnCreateAndUpdate() throws Exception { String padded = " ".repeat(25) + "A".repeat(40) + " ".repeat(25); var payload = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(createBody("福利trim", "12")); payload.putArray("benefits").add(padded); String body = mvc.perform(post("/jobs").header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(payload))) .andExpect(status().isOk()).andExpect(jsonPath("$.data.benefits[0]").value("A".repeat(40))) .andReturn().getResponse().getContentAsString(); long id = mapper.readTree(body).path("data").path("id").asLong(); var update = mapper.createObjectNode(); var benefits = update.putArray("benefits"); for (int i = 0; i < 21; i++) benefits.add(padded); mvc.perform(put("/jobs/" + id).header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(update))) .andExpect(status().isOk()).andExpect(jsonPath("$.data.benefits.length()").value(1)); update.putArray("benefits").add("A".repeat(41)); mvc.perform(put("/jobs/" + id).header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(update))) .andExpect(status().isBadRequest()); assertEquals("[\"" + "A".repeat(40) + "\"]", jdbc.queryForObject("SELECT benefits_json FROM jobs WHERE id=?", String.class, id)); } @Test void benefitsTwentyDistinctAcceptedTwentyOneRejectedWithoutPartialUpdate() throws Exception { long id = createJob("福利数量", "12"); var update = mapper.createObjectNode(); var benefits = update.putArray("benefits"); for (int i = 0; i < 20; i++) benefits.add("B" + i); mvc.perform(put("/jobs/" + id).header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(update))) .andExpect(status().isOk()).andExpect(jsonPath("$.data.benefits.length()").value(20)); String before = jdbc.queryForObject("SELECT benefits_json FROM jobs WHERE id=?", String.class, id); benefits.add("B20"); update.put("title", "不得写入"); mvc.perform(put("/jobs/" + id).header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(update))) .andExpect(status().isBadRequest()); assertEquals(before, jdbc.queryForObject("SELECT benefits_json FROM jobs WHERE id=?", String.class, id)); assertEquals("福利数量", jdbc.queryForObject("SELECT title FROM jobs WHERE id=?", String.class, id)); update.putNull("benefits"); update.remove("title"); mvc.perform(put("/jobs/" + id).header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(update))) .andExpect(status().isOk()).andExpect(jsonPath("$.data.benefits.length()").value(20)); } @Test void integersOneAndTwentyFourAreAcceptedOnCreate() throws Exception { long job1 = createJob("整数1", "1"); assertEquals(1, salaryMonthsInDb(job1)); assertEquals(1, responseSalaryMonths(job1)); long job24 = createJob("整数24", "24"); assertEquals(24, salaryMonthsInDb(job24)); assertEquals(24, responseSalaryMonths(job24)); } // ── 越界整数 ──────────────────────────────────────────────── @Test void outOfRangeIntegersAreRejectedWithoutPartialWrite() throws Exception { long jobsBefore = jobCount(); for (String raw : new String[]{"0", "-1", "25"}) { mvc.perform(post("/jobs").header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content(createBody("越界", raw))) .andExpect(status().isBadRequest()); } assertEquals(jobsBefore, jobCount(), "越界写请求不得新增任何岗位记录"); } // ── 小数 ──────────────────────────────────────────────────── @Test void fractionalNumbersAreRejected() throws Exception { long jobsBefore = jobCount(); for (String raw : new String[]{"1.9", "24.9", "1.0"}) { mvc.perform(post("/jobs").header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content(createBody("小数", raw))) .andExpect(status().isBadRequest()); } assertEquals(jobsBefore, jobCount(), "小数写请求不得新增任何岗位记录"); } // ── 错误类型 ──────────────────────────────────────────────── @Test void wrongJsonTypesAreRejected() throws Exception { long jobsBefore = jobCount(); for (String raw : new String[]{"\"12\"", "\"\"", "true", "[]", "{}"}) { mvc.perform(post("/jobs").header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content(createBody("错类型", raw))) .andExpect(status().isBadRequest()); } assertEquals(jobsBefore, jobCount(), "类型错误的写请求不得新增任何岗位记录"); } @Test void integerOutOfIntRangeIsRejected() throws Exception { mvc.perform(post("/jobs").header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content(createBody("超范围", "99999999999"))) .andExpect(status().isBadRequest()); } // ── 缺省 / null:创建存 NULL,更新保持原值 ────────────────── @Test void missingAndNullOnCreateStoreNull() throws Exception { mvc.perform(post("/jobs").header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content(createBodyNoSalary("缺省"))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.salaryMonths").doesNotExist()); assertNull(firstJobSalaryMonths()); long job = createJob("显式null", "null"); assertNull(salaryMonthsInDb(job)); } @Test void nullOnUpdateKeepsOriginal() throws Exception { long jobId = createJob("更新保持", "14"); assertEquals(14, salaryMonthsInDb(jobId)); // 缺省 mvc.perform(put("/jobs/" + jobId).header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content("{\"title\":\"更新保持-缺省\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.salaryMonths").value(14)); assertEquals(14, salaryMonthsInDb(jobId)); // 显式 null mvc.perform(put("/jobs/" + jobId).header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content("{\"title\":\"更新保持-null\",\"salaryMonths\":null}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.salaryMonths").value(14)); assertEquals(14, salaryMonthsInDb(jobId)); } // ── 失败写请求:无部分更新 ────────────────────────────────── @Test void failedCreateWithValidTitleDoesNotPersistAnything() throws Exception { mvc.perform(post("/jobs").header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"SHOULD_NOT_PERSIST\",\"description\":\"d\",\"city\":\"c\"," + "\"employmentType\":\"FULL_TIME\",\"salaryMonths\":25}")) .andExpect(status().isBadRequest()); Integer count = jdbc.queryForObject( "select count(*) from jobs where title = ?", Integer.class, "SHOULD_NOT_PERSIST"); assertEquals(0, count, "失败的创建请求不得留下标题已写入的记录"); } @Test void failedUpdateDoesNotPartiallyChangeValidFields() throws Exception { long jobId = createJob("原标题", "14"); mvc.perform(put("/jobs/" + jobId).header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"PARTIAL_TITLE\",\"salaryMonths\":25}")) .andExpect(status().isBadRequest()); mvc.perform(get("/jobs/" + jobId).header("Authorization", bearer())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.title").value("原标题")) .andExpect(jsonPath("$.data.salaryMonths").value(14)); assertEquals("原标题", jdbc.queryForObject( "select title from jobs where id = ?", String.class, jobId)); assertEquals(14, salaryMonthsInDb(jobId)); } // ── 探针等价断言:DTO 级严格整数(正式测试) ───────────────── @Test void objectMapperRejectsFractionalSalaryMonthsAtDtoLevel() { assertThrows(com.fasterxml.jackson.core.JsonProcessingException.class, () -> mapper.readValue("{\"salaryMonths\":1.9}", com.mydreamjob.job.dto.JobCreateRequest.class)); assertThrows(com.fasterxml.jackson.core.JsonProcessingException.class, () -> mapper.readValue("{\"salaryMonths\":1.0}", com.mydreamjob.job.dto.JobUpdateRequest.class)); } // ── Helper ────────────────────────────────────────────────── private long createJob(String title, String rawSalaryMonthsJsonOrNull) throws Exception { String body = rawSalaryMonthsJsonOrNull == null ? createBodyNoSalary(title) : createBody(title, rawSalaryMonthsJsonOrNull); String response = mvc.perform(post("/jobs").header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); return mapper.readTree(response).path("data").path("id").asLong(); } private String createBody(String title, String rawSalaryMonthsJson) { return "{\"title\":\"" + title + "\",\"description\":\"d\",\"city\":\"c\"," + "\"employmentType\":\"FULL_TIME\",\"salaryMonths\":" + rawSalaryMonthsJson + "}"; } private String createBodyNoSalary(String title) { return "{\"title\":\"" + title + "\",\"description\":\"d\",\"city\":\"c\"," + "\"employmentType\":\"FULL_TIME\"}"; } private int responseSalaryMonths(long jobId) throws Exception { String response = mvc.perform(get("/jobs/" + jobId).header("Authorization", bearer())) .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); JsonNode node = mapper.readTree(response).path("data").path("salaryMonths"); return node.isMissingNode() || node.isNull() ? -1 : node.asInt(); } private Integer salaryMonthsInDb(long jobId) { return jdbc.queryForObject("select salary_months from jobs where id = ?", Integer.class, jobId); } private Integer firstJobSalaryMonths() { List values = jdbc.queryForList( "select salary_months from jobs where company_id = ?", Integer.class, companyId); assertEquals(1, values.size()); return values.get(0); } private long jobCount() { return jdbc.queryForObject("select count(*) from jobs where company_id = ?", Long.class, companyId); } private String bearer() { return "Bearer " + token; } }