forked from baskbuddy/zhangshun
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.
334 lines
18 KiB
334 lines
18 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 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<String> PUBLIC_COMPANY_KEYS =
|
|
Set.of("id", "name", "logoUrl", "description", "industry", "verified", "companySize");
|
|
|
|
@MockBean private RedisTemplate<String, Object> 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<String> fieldNames(JsonNode node) {
|
|
Set<String> names = new HashSet<>();
|
|
node.fieldNames().forEachRemaining(names::add);
|
|
return names;
|
|
}
|
|
|
|
private static List<String> strings(JsonNode array) {
|
|
List<String> values = new ArrayList<>();
|
|
array.forEach(n -> values.add(n.asText()));
|
|
return values;
|
|
}
|
|
|
|
private String bearer() {
|
|
return "Bearer " + token;
|
|
}
|
|
}
|
|
|