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 org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.LocalDateTime; import java.util.ArrayList; 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.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; 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; /** * 岗位新增展示字段(experience / education / benefits / salaryMonths / workLocation)在各读取接口的一致返回、 * 字符串与数组边界、公开企业摘要白名单、草稿可见性,以及历史 NULL 数据读取。 */ @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") @TestPropertySource(properties = "spring.datasource.url=jdbc:h2:mem:display_fields_consistency;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") class JobDisplayFieldsConsistencyIntegrationTest { private static final AtomicInteger SEQ = new AtomicInteger(); private static final String PHONE_PREFIX = "1990078"; private static final String CITY = "CONSISTENCYCITY"; private static final Set PUBLIC_COMPANY_KEYS = Set.of("id", "name", "logoUrl", "description", "industry", "verified", "companySize"); @MockBean private RedisTemplate redis; @Autowired private MockMvc mvc; @Autowired private JdbcTemplate jdbc; @Autowired private ObjectMapper mapper; @Autowired private UserRepository users; @Autowired private CompanyRepository companies; @Autowired private AuthSessionService sessions; 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("展示一致性企业") .logoUrl("https://logo.example/consistency.png") .description("企业简介") .industry("互联网") .companySize("100-499人") .address("企业登记地址不应外泄") .website("https://consistency.example") .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); } // ── 1. 各接口字段一致 ─────────────────────────────────────── @Test void newFieldsAreConsistentAcrossCreateUpdateAndAllReadEndpoints() throws Exception { long jobId = createFullJob("一致性岗位"); publish(jobId); // GET /jobs → data.items[0] assertFields(data(get("/jobs").param("city", CITY)).at("/items/0")); // GET /jobs/search → data.items[0] assertFields(data(get("/jobs/search").param("city", CITY) .param("companyId", String.valueOf(companyId))).at("/items/0")); // GET /jobs/public/{jobId} → data 本身 assertFields(data(get("/jobs/public/" + jobId))); // GET /companies/{companyId}/jobs → data.items[0] assertFields(data(get("/companies/" + companyId + "/jobs")).at("/items/0")); // GET /companies/me/jobs(企业自身岗位列表)→ data.items[0] assertFields(data(get("/companies/me/jobs").header("Authorization", bearer())).at("/items/0")); // GET /jobs/{jobId}(企业岗位详情)→ data 本身 assertFields(data(get("/jobs/" + jobId).header("Authorization", bearer()))); // PUT /jobs/{jobId} 响应 → data 本身 JsonNode updated = data(put("/jobs/" + jobId).header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content("{\"title\":\"一致性岗位-更新\"}")); assertFields(updated); assertEquals("一致性岗位-更新", updated.path("title").asText()); } // ── 2. 字符串长度边界 ─────────────────────────────────────── @Test void experienceEducationAndWorkLocationLengthBoundaries() throws Exception { mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) .content(jobBody("\"experience\":\"" + "e".repeat(40) + "\""))) .andExpect(status().isOk()); mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) .content(jobBody("\"experience\":\"" + "e".repeat(41) + "\""))) .andExpect(status().isBadRequest()); mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) .content(jobBody("\"education\":\"" + "d".repeat(40) + "\""))) .andExpect(status().isOk()); mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) .content(jobBody("\"education\":\"" + "d".repeat(41) + "\""))) .andExpect(status().isBadRequest()); mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) .content(jobBody("\"workLocation\":\"" + "w".repeat(255) + "\""))) .andExpect(status().isOk()); mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) .content(jobBody("\"workLocation\":\"" + "w".repeat(256) + "\""))) .andExpect(status().isBadRequest()); } // ── 3. benefits 规则 ──────────────────────────────────────── @Test void benefitsArrayRulesAndDeduplicationKeepOrder() throws Exception { mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) .content(jobBody("\"benefits\":" + jsonArray(20, 5)))) .andExpect(status().isOk()); mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) .content(jobBody("\"benefits\":" + jsonArray(21, 5)))) .andExpect(status().isBadRequest()); mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) .content(jobBody("\"benefits\":[\"" + "b".repeat(40) + "\"]"))) .andExpect(status().isOk()); mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) .content(jobBody("\"benefits\":[\"" + "b".repeat(41) + "\"]"))) .andExpect(status().isBadRequest()); mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) .content(jobBody("\"benefits\":[\"\"]"))) .andExpect(status().isBadRequest()); mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) .content(jobBody("\"benefits\":[\" \"]"))) .andExpect(status().isBadRequest()); long jobId = createFullJob("去重岗位"); String response = mvc.perform(put("/jobs/" + jobId).header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON) .content("{\"benefits\":[\"A\",\"B\",\"A\",\"C\",\"B\"]}")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); assertEquals(List.of("A", "B", "C"), strings(mapper.readTree(response).path("data").path("benefits"))); } @Test void emptyBenefitsArrayClearsToNullThenReadsAsEmptyArray() throws Exception { long jobId = createFullJob("清空福利"); publish(jobId); mvc.perform(put("/jobs/" + jobId).header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content("{\"benefits\":[]}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.benefits.length()").value(0)); assertEquals(null, jdbc.queryForObject( "select benefits_json from jobs where id = ?", String.class, jobId)); JsonNode publicData = data(get("/jobs/public/" + jobId)); assertTrue(publicData.path("benefits").isArray()); assertEquals(0, publicData.path("benefits").size()); } // ── 4. 公开企业摘要白名单 ─────────────────────────────────── @Test void publicCompanySummaryIsExactlySevenFieldWhitelist() throws Exception { long jobId = createFullJob("白名单岗位"); publish(jobId); String companyRaw = raw(get("/companies/" + companyId)); assertEquals(PUBLIC_COMPANY_KEYS, fieldNames(mapper.readTree(companyRaw).path("data"))); assertEquals(PUBLIC_COMPANY_KEYS, fieldNames(data(get("/jobs/public/" + jobId)).path("company"))); for (String forbidden : new String[]{ "ownerUserId", "address", "website", "createdAt", "企业登记地址不应外泄", "phone", "password"}) { assertTrue(!companyRaw.contains(forbidden), "公开企业摘要不得包含敏感字段或值:" + forbidden); } JsonNode companyNode = mapper.readTree(companyRaw).path("data"); assertTrue(companyNode.path("ownerUserId").isMissingNode(), "公开企业摘要不得出现 ownerUserId 字段"); companyNode.forEach(value -> assertTrue(!(value.isNumber() && value.asLong() == userId), "公开企业摘要不得包含企业主 ID")); assertEquals("100-499人", companyNode.path("companySize").asText()); } // ── 5. 草稿不可见、已关闭仍可见 ───────────────────────────── @Test void draftIsNotPubliclyVisibleAndClosedRemainsVisible() throws Exception { long draftId = createFullJob("草稿岗位"); mvc.perform(get("/jobs/public/" + draftId)).andExpect(status().isNotFound()); publish(draftId); mvc.perform(get("/jobs/public/" + draftId)).andExpect(status().isOk()); mvc.perform(patch("/jobs/" + draftId + "/status").header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content("{\"status\":\"CLOSED\"}")) .andExpect(status().isOk()); mvc.perform(get("/jobs/public/" + draftId)).andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("CLOSED")); } // ── 6. 历史 NULL 数据读取 ────────────────────────────────── @Test void legacyRowsWithNullNewFieldsReadCleanlyWithoutFabrication() throws Exception { int companiesBefore = jdbc.queryForObject("select count(*) from companies", Integer.class); LocalDateTime now = LocalDateTime.now(); jdbc.update("insert into jobs (company_id,title,description,city,employment_type,status,created_at,updated_at)" + " values (?,?,?,?,?,?,?,?)", companyId, "历史岗位", "描述", CITY, "FULL_TIME", "PUBLISHED", now, now); long legacyId = jdbc.queryForObject( "select id from jobs where company_id = ? and title = ?", Long.class, companyId, "历史岗位"); JsonNode data = data(get("/jobs/public/" + legacyId)); assertTrue(data.path("experience").isNull() || data.path("experience").isMissingNode()); assertTrue(data.path("education").isNull() || data.path("education").isMissingNode()); assertTrue(data.path("salaryMonths").isNull() || data.path("salaryMonths").isMissingNode()); assertTrue(data.path("workLocation").isNull() || data.path("workLocation").isMissingNode()); assertTrue(data.path("benefits").isArray(), "benefits 对外恒为数组"); assertEquals(0, data.path("benefits").size(), "NULL benefits_json 应返回空数组"); assertEquals(companiesBefore, jdbc.queryForObject("select count(*) from companies", Integer.class), "读取历史数据不得制造默认企业"); } // ── Helper ────────────────────────────────────────────────── private void assertFields(JsonNode item) { assertEquals("3-5年", item.path("experience").asText()); assertEquals("本科", item.path("education").asText()); assertEquals(14, item.path("salaryMonths").asInt()); assertEquals("上海市浦东新区", item.path("workLocation").asText()); assertEquals(List.of("五险一金", "弹性工作"), strings(item.path("benefits"))); } private long createFullJob(String title) throws Exception { String response = mvc.perform(post("/jobs").header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"" + title + "\",\"description\":\"岗位描述\",\"requirements\":\"岗位要求\"," + "\"city\":\"" + CITY + "\",\"employmentType\":\"FULL_TIME\"," + "\"salaryMin\":20000,\"salaryMax\":40000,\"experience\":\"3-5年\"," + "\"education\":\"本科\",\"benefits\":[\"五险一金\",\"弹性工作\"]," + "\"salaryMonths\":14,\"workLocation\":\"上海市浦东新区\"}")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); return mapper.readTree(response).path("data").path("id").asLong(); } private void publish(long jobId) throws Exception { mvc.perform(patch("/jobs/" + jobId + "/status").header("Authorization", bearer()) .contentType(MediaType.APPLICATION_JSON).content("{\"status\":\"PUBLISHED\"}")) .andExpect(status().isOk()); } private String jobBody(String extraField) { return "{\"title\":\"岗位\",\"description\":\"岗位描述\",\"city\":\"" + CITY + "\"," + "\"employmentType\":\"FULL_TIME\"," + extraField + "}"; } private static String jsonArray(int size, int itemLength) { StringBuilder sb = new StringBuilder("["); for (int i = 0; i < size; i++) { if (i > 0) sb.append(','); String base = "v" + i; int pad = Math.max(1, itemLength - base.length()); sb.append('"').append(base).append("x".repeat(pad)).append('"'); } return sb.append(']').toString(); } private JsonNode data(MockHttpServletRequestBuilder builder) throws Exception { return mapper.readTree(raw(builder)).path("data"); } private String raw(MockHttpServletRequestBuilder builder) throws Exception { return mvc.perform(builder).andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); } private static Set fieldNames(JsonNode node) { Set names = new HashSet<>(); node.fieldNames().forEachRemaining(names::add); return names; } private static List strings(JsonNode array) { List values = new ArrayList<>(); array.forEach(n -> values.add(n.asText())); return values; } private String bearer() { return "Bearer " + token; } }