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 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()); } }