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.
330 lines
17 KiB
330 lines
17 KiB
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<String, Object> 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<Integer> 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;
|
|
}
|
|
}
|
|
|