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.
 
 
 

203 lines
14 KiB

package com.mydreamjob.job;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.auth.*;
import com.mydreamjob.company.*;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
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.*;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/** H2 全栈验证:真实 Controller 绑定、鉴权、Flyway V60、持久化和运行时契约;不联网。 */
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties="spring.datasource.url=jdbc:h2:mem:job_map_location;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
@Transactional
class JobMapLocationIntegrationTest {
private static final AtomicInteger SEQ=new AtomicInteger();
private static final String LOCATION="""
{"status":"CONFIRMED","longitude":116.397463,"latitude":39.909187,
"coordinateSystem":"GCJ02","poiId":" public-landmark ","poiName":" 公开地标测试 "}
""";
@Autowired MockMvc mvc;
@Autowired ObjectMapper mapper;
@Autowired UserRepository users;
@Autowired CompanyRepository companies;
@Autowired AuthSessionService sessions;
@Autowired EntityManager em;
@Autowired JdbcTemplate jdbc;
@MockBean RedisTemplate<String,Object> redis;
@MockBean com.mydreamjob.job.location.AmapPlaceClient placeClient;
@MockBean com.mydreamjob.job.location.JobLocationSearchLimiter searchLimiter;
private long companyId;
private String token;
@BeforeEach void setup(){
var owner=users.saveAndFlush(User.builder().phone("1990088"+String.format("%04d",SEQ.incrementAndGet()))
.role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).active(true).verified(true).build());
companyId=companies.saveAndFlush(Company.builder().ownerUserId(owner.getId())
.name("岗位地图隔离测试企业").address("注册地址不得作为工作点位").verified(true).build()).getId();
token=sessions.createSession(owner,Instant.now(),new AuditContext()).accessToken();
}
@Test void createPersistReadAndPublish() throws Exception {
long id=create(LOCATION);
em.flush();em.clear();
mvc.perform(get("/jobs/"+id).header("Authorization","Bearer "+token))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.mapLocation.provider").value("AMAP"))
.andExpect(jsonPath("$.data.mapLocation.longitude").value(116.397463))
.andExpect(jsonPath("$.data.mapLocation.poiId").value("public-landmark"));
mvc.perform(get("/jobs/public/"+id)).andExpect(status().isNotFound());
mvc.perform(patch("/jobs/"+id+"/status").header("Authorization","Bearer "+token)
.contentType(MediaType.APPLICATION_JSON).content("{\"status\":\"PUBLISHED\"}"))
.andExpect(status().isOk());
mvc.perform(get("/jobs/public/"+id)).andExpect(status().isOk())
.andExpect(jsonPath("$.data.mapLocation.status").value("CONFIRMED"))
.andExpect(jsonPath("$.data.mapLocation.coordinateSystem").value("GCJ02"));
mvc.perform(patch("/jobs/"+id+"/status").header("Authorization","Bearer "+token)
.contentType(MediaType.APPLICATION_JSON).content("{\"status\":\"CLOSED\"}"))
.andExpect(status().isOk());
mvc.perform(get("/jobs/public/"+id)).andExpect(status().isOk());
}
@Test void legacyDefaultAndMigrationColumns() throws Exception {
long id=create(null);
mvc.perform(get("/jobs/"+id).header("Authorization","Bearer "+token))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.mapLocation.status").value("UNSET"))
.andExpect(jsonPath("$.data.mapLocation.longitude").isEmpty());
assertEquals(1,jdbc.queryForObject("select count(*) from \"flyway_schema_history\" where \"version\"='60' and \"success\"=true",Integer.class));
jdbc.update("insert into jobs(company_id,title,description,city,employment_type,status) values(?,?,?,?,?,?)",
companyId,"旧版 SQL 岗位","描述","北京","FULL_TIME","DRAFT");
assertEquals("UNSET",jdbc.queryForObject("select map_location_status from jobs where title=?",String.class,"旧版 SQL 岗位"));
}
@Test void updateAddressInvalidatesAndExplicitUnsetKeepsAddress() throws Exception {
long id=create(LOCATION);
update(id,"{\"title\":\"仅改标题\"}").andExpect(jsonPath("$.data.mapLocation.status").value("CONFIRMED"));
update(id,"{\"workLocation\":\"新工作地址\"}").andExpect(jsonPath("$.data.mapLocation.status").value("UNSET"))
.andExpect(jsonPath("$.data.mapLocation.longitude").isEmpty());
update(id,"{\"mapLocation\":"+LOCATION+"}").andExpect(jsonPath("$.data.mapLocation.status").value("CONFIRMED"));
update(id,"{\"mapLocation\":{\"status\":\"UNSET\"}}")
.andExpect(jsonPath("$.data.mapLocation.poiId").isEmpty())
.andExpect(jsonPath("$.data.workLocation").value("新工作地址"));
}
@ParameterizedTest
@ValueSource(strings={"{}","{\"status\":\"UNKNOWN\"}",
"{\"status\":\"CONFIRMED\",\"longitude\":116,\"coordinateSystem\":\"GCJ02\"}",
"{\"status\":\"CONFIRMED\",\"longitude\":181,\"latitude\":39,\"coordinateSystem\":\"GCJ02\"}",
"{\"status\":\"CONFIRMED\",\"longitude\":116,\"latitude\":91,\"coordinateSystem\":\"GCJ02\"}",
"{\"status\":\"CONFIRMED\",\"longitude\":116,\"latitude\":39,\"coordinateSystem\":\"BD09\"}",
"{\"status\":\"CONFIRMED\",\"longitude\":116.1234567,\"latitude\":39,\"coordinateSystem\":\"GCJ02\"}",
"{\"status\":\"UNSET\",\"longitude\":116}",
"{\"status\":\"CONFIRMED\",\"longitude\":[],\"latitude\":39,\"coordinateSystem\":\"GCJ02\"}",
"{\"status\":\"CONFIRMED\",\"longitude\":\"116.3\",\"latitude\":39,\"coordinateSystem\":\"GCJ02\"}",
"{\"status\":\"CONFIRMED\",\"longitude\":116,\"latitude\":true,\"coordinateSystem\":\"GCJ02\"}",
"{\"status\":\"CONFIRMED\",\"longitude\":116,\"latitude\":39,\"coordinateSystem\":\"GCJ02\",\"poiName\":\"a\\nb\"}"})
void rejectInvalidBindingWithoutPartialWrite(String location) throws Exception {
long id=create(LOCATION);
mvc.perform(put("/jobs/"+id).header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"不得保存\",\"mapLocation\":"+location+"}"))
.andExpect(status().isBadRequest());
mvc.perform(get("/jobs/"+id).header("Authorization","Bearer "+token))
.andExpect(jsonPath("$.data.title").value("地图测试岗位"))
.andExpect(jsonPath("$.data.mapLocation.status").value("CONFIRMED"));
}
@Test void missingAddressAndAnonymousWriteRejected() throws Exception {
var body=mapper.readTree(createBody(LOCATION)).deepCopy();
((com.fasterxml.jackson.databind.node.ObjectNode)body).put("workLocation","");
mvc.perform(post("/jobs").header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(body))).andExpect(status().isBadRequest());
mvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(createBody(LOCATION)))
.andExpect(status().isUnauthorized());
}
@Test void otherCompanyCannotModifyLocation() throws Exception {
long id=create(LOCATION);
var other=users.saveAndFlush(User.builder().phone("1990088"+String.format("%04d",SEQ.incrementAndGet()))
.role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).active(true).verified(true).build());
companies.saveAndFlush(Company.builder().ownerUserId(other.getId()).name("另一测试企业").verified(true).build());
String otherToken=sessions.createSession(other,Instant.now(),new AuditContext()).accessToken();
mvc.perform(put("/jobs/"+id).header("Authorization","Bearer "+otherToken).contentType(MediaType.APPLICATION_JSON)
.content("{\"mapLocation\":{\"status\":\"UNSET\"}}")).andExpect(status().isNotFound());
}
@Test void runtimeContractContainsLocationAndSearchRoute() throws Exception {
String raw=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
var spec=mapper.readTree(raw);assertEquals(206,spec.path("paths").size());
assertTrue(spec.path("paths").path("/companies/me/job-location-suggestions").has("get"));
var search=spec.path("paths").path("/companies/me/job-location-suggestions").path("get");
assertEquals("searchJobLocationSuggestions",search.path("operationId").asText());
for(String code:java.util.List.of("200","400","401","403","404","429","503"))
assertTrue(search.path("responses").has(code),"missing response "+code);
assertTrue(search.path("responses").path("200").path("content").has("application/json"));
var schemas=spec.path("components").path("schemas");
assertTrue(schemas.path("JobCreateRequest").path("properties").has("mapLocation"));
assertTrue(schemas.path("JobUpdateRequest").path("properties").has("mapLocation"));
assertTrue(schemas.path("JobPublicViewDoc").path("properties").has("mapLocation"));
assertTrue(schemas.path("JobMapLocationView").path("properties").has("coordinateSystem"));
assertEquals("^GCJ02$",schemas.path("JobMapLocationView").path("properties").path("coordinateSystem").path("pattern").asText());
assertTrue(schemas.path("JobMapLocationRequest").path("type").toString().contains("null"));
String output=System.getProperty("job.map.runtimeContract");
if(output!=null)Files.writeString(Path.of(output),raw);
}
@Test void locationSearchRealAuthenticationBoundary() throws Exception {
String path="/companies/me/job-location-suggestions";
mvc.perform(get(path).param("city","北京").param("keyword","测试路"))
.andExpect(status().isUnauthorized());
var student=users.saveAndFlush(User.builder().phone("1990088"+String.format("%04d",SEQ.incrementAndGet()))
.role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(true).build());
String studentToken=sessions.createSession(student,Instant.now(),new AuditContext()).accessToken();
mvc.perform(get(path).header("Authorization","Bearer "+studentToken).param("city","北京").param("keyword","测试路"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002));
org.mockito.Mockito.verifyNoInteractions(placeClient,searchLimiter);
org.mockito.Mockito.when(placeClient.configured()).thenReturn(true);
org.mockito.Mockito.when(placeClient.search("北京","测试路")).thenReturn(java.util.List.of());
mvc.perform(get(path).header("Authorization","Bearer "+token).param("city","北京").param("keyword","测试路"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.items").isEmpty());
org.mockito.Mockito.verify(searchLimiter).acquire(companyId);
var owner=users.findById(companies.findById(companyId).orElseThrow().getOwnerUserId()).orElseThrow();
jdbc.update("update users set is_active=false where id=?",owner.getId());em.clear();
mvc.perform(get(path).header("Authorization","Bearer "+token).param("city","北京").param("keyword","测试路"))
.andExpect(status().isUnauthorized());
org.mockito.Mockito.verify(placeClient,org.mockito.Mockito.times(1)).search("北京","测试路");
}
private long create(String location) throws Exception {
String raw=mvc.perform(post("/jobs").header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON)
.content(createBody(location))).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
return mapper.readTree(raw).path("data").path("id").asLong();
}
private String createBody(String location){
return "{\"title\":\"地图测试岗位\",\"description\":\"合成测试\",\"city\":\"北京\",\"employmentType\":\"FULL_TIME\",\"workLocation\":\"实际工作地址\""
+(location==null?"":",\"mapLocation\":"+location)+"}";
}
private org.springframework.test.web.servlet.ResultActions update(long id,String body) throws Exception {
return mvc.perform(put("/jobs/"+id).header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON)
.content(body)).andExpect(status().isOk());
}
}