package com.mydreamjob.offer; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; 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.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; /** * Offer 接口集成测试:JWT 鉴权、统一响应结构、权限、业务错误、OpenAPI 一致性。 * 激活 profile: integration-test(H2 内嵌数据库 + Flyway V13 + Redis/Rabbit 关闭) */ @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") class OfferControllerTest { @Autowired private MockMvc mockMvc; @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; @MockBean private OfferService offerService; @MockBean private RedisTemplate redisTemplate; private final ObjectMapper mapper = new ObjectMapper(); private static final Long COMPANY_USER_ID = 10L; private static final Long STUDENT_ID = 100L; private static final Long OFFER_ID = 1L; @BeforeEach @SuppressWarnings("unchecked") void setUp() { ValueOperations ops = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(any())).thenReturn(false); } private String companyToken() { return com.mydreamjob.auth.support.TestAuthTokens.bearer(COMPANY_USER_ID, "COMPANY"); } private String studentToken() { return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT"); } private Map offerView() { return Map.of("id", OFFER_ID, "applicationId", 5L, "jobId", 10L, "companyId", 1L, "candidateId", STUDENT_ID, "title", "后端工程师", "status", "DRAFT", "salaryMin", 10000, "salaryMax", 20000); } private void stubOfferView() { when(offerService.toView(any())).thenReturn(offerView()); } private String createBody() { return """ { "applicationId": 5, "title": "后端工程师", "salaryMin": 10000, "salaryMax": 20000, "workCity": "杭州", "description": "负责服务端开发", "expireAt": "2030-12-31T23:59:59" } """; } // ── 创建 ────────────────────────────────────────────────── @Test void create_shouldReturn200WithUnifiedResponse() throws Exception { stubOfferView(); when(offerService.createOffer(eq(COMPANY_USER_ID), any(), any())).thenReturn(Offer.builder().id(OFFER_ID).build()); mockMvc.perform(post("/offers") .header("Authorization", companyToken()) .header("X-Request-Id", "req-abc") .contentType(MediaType.APPLICATION_JSON).content(createBody())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.message").value("ok")) .andExpect(jsonPath("$.data.id").value(OFFER_ID)) .andExpect(jsonPath("$.requestId").value("req-abc")) .andExpect(jsonPath("$.timestamp").isNotEmpty()); } @Test void create_invalidBody_shouldReturn400() throws Exception { mockMvc.perform(post("/offers") .header("Authorization", companyToken()) .contentType(MediaType.APPLICATION_JSON).content("{}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(400)); } @Test void create_noToken_shouldReturn401() throws Exception { mockMvc.perform(post("/offers") .contentType(MediaType.APPLICATION_JSON).content(createBody())) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.code").value(401)); } @Test void create_byStudent_shouldReturn403() throws Exception { when(offerService.createOffer(eq(STUDENT_ID), any(), any())) .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); mockMvc.perform(post("/offers") .header("Authorization", studentToken()) .contentType(MediaType.APPLICATION_JSON).content(createBody())) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); } // ── 列表 ────────────────────────────────────────────────── @Test void list_shouldReturn200WithPagination() throws Exception { stubOfferView(); Page page = new PageImpl<>(List.of(Offer.builder().id(OFFER_ID).build()), org.springframework.data.domain.PageRequest.of(0, 20), 1); when(offerService.listOffers(eq(STUDENT_ID), isNull(), isNull(), eq(0), eq(20))).thenReturn(page); mockMvc.perform(get("/offers").header("Authorization", studentToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.items").isArray()) .andExpect(jsonPath("$.data.total").isNumber()) .andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.size").value(20)); } @Test void list_noToken_shouldReturn401() throws Exception { mockMvc.perform(get("/offers")).andExpect(status().isUnauthorized()); } // ── 详情 ────────────────────────────────────────────────── @Test void get_shouldReturn200() throws Exception { stubOfferView(); when(offerService.getOffer(OFFER_ID, STUDENT_ID)).thenReturn(Offer.builder().id(OFFER_ID).build()); mockMvc.perform(get("/offers/" + OFFER_ID).header("Authorization", studentToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.status").value("DRAFT")); } @Test void get_otherUser_shouldReturn403() throws Exception { when(offerService.getOffer(OFFER_ID, STUDENT_ID)) .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); mockMvc.perform(get("/offers/" + OFFER_ID).header("Authorization", studentToken())) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); } @Test void get_notFound_shouldReturn404() throws Exception { when(offerService.getOffer(999L, STUDENT_ID)) .thenThrow(new BusinessException(ErrorCode.OFFER_NOT_FOUND)); mockMvc.perform(get("/offers/999").header("Authorization", studentToken())) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value(ErrorCode.OFFER_NOT_FOUND.getCode())); } // ── 修改 ────────────────────────────────────────────────── @Test void update_sentOffer_shouldReturn409() throws Exception { when(offerService.updateOffer(eq(COMPANY_USER_ID), eq(OFFER_ID), any(), any())) .thenThrow(new BusinessException(ErrorCode.OFFER_NOT_EDITABLE)); mockMvc.perform(put("/offers/" + OFFER_ID) .header("Authorization", companyToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"新标题\"}")) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(ErrorCode.OFFER_NOT_EDITABLE.getCode())); } @Test void update_draft_shouldReturn200() throws Exception { stubOfferView(); when(offerService.updateOffer(eq(COMPANY_USER_ID), eq(OFFER_ID), any(), any())) .thenReturn(Offer.builder().id(OFFER_ID).build()); mockMvc.perform(put("/offers/" + OFFER_ID) .header("Authorization", companyToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"新标题\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.title").value("后端工程师")); } // ── 状态变更 ────────────────────────────────────────────── @Test void changeStatus_shouldReturn200() throws Exception { stubOfferView(); when(offerService.transitionStatus(eq(STUDENT_ID), eq(OFFER_ID), any(), any())) .thenReturn(Offer.builder().id(OFFER_ID).build()); mockMvc.perform(patch("/offers/" + OFFER_ID + "/status") .header("Authorization", studentToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"ACCEPTED\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); } @Test void changeStatus_invalidTransition_shouldReturn409() throws Exception { when(offerService.transitionStatus(eq(STUDENT_ID), eq(OFFER_ID), any(), any())) .thenThrow(new BusinessException(ErrorCode.INVALID_OFFER_STATUS_TRANSITION)); mockMvc.perform(patch("/offers/" + OFFER_ID + "/status") .header("Authorization", studentToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"REJECTED\"}")) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_OFFER_STATUS_TRANSITION.getCode())); } @Test void changeStatus_invalidStatusValue_shouldReturn400() throws Exception { mockMvc.perform(patch("/offers/" + OFFER_ID + "/status") .header("Authorization", studentToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"INVALID\"}")) .andExpect(status().isBadRequest()); } // ── 时间线 ──────────────────────────────────────────────── @Test void timeline_shouldReturn200() throws Exception { when(offerService.getTimeline(OFFER_ID, STUDENT_ID)).thenReturn(List.of( Map.of("fromStatus", "DRAFT", "toStatus", "SENT", "operatorRole", "COMPANY"))); mockMvc.perform(get("/offers/" + OFFER_ID + "/timeline").header("Authorization", studentToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data[0].toStatus").value("SENT")); } // ── 提醒 ────────────────────────────────────────────────── @Test void remind_shouldReturn200() throws Exception { OfferReminder reminder = OfferReminder.builder().offerId(OFFER_ID) .reminderType(OfferReminder.TYPE_MANUAL).status(OfferReminder.STATUS_SENT).build(); when(offerService.remind(eq(COMPANY_USER_ID), eq(OFFER_ID), any())).thenReturn(reminder); mockMvc.perform(post("/offers/" + OFFER_ID + "/remind").header("Authorization", companyToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.reminderType").value("MANUAL")) .andExpect(jsonPath("$.code").value(200)); } // ── OpenAPI 一致性 ──────────────────────────────────────── @Test void openApi_shouldContainOfferEndpoints() throws Exception { String json = mockMvc.perform(get("/v3/api-docs")) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); JsonNode root = mapper.readTree(json); JsonNode paths = root.path("paths"); assertThat(paths.has("/offers")).isTrue(); assertThat(paths.has("/offers/{id}")).isTrue(); assertThat(paths.has("/offers/{id}/status")).isTrue(); assertThat(paths.has("/offers/{id}/timeline")).isTrue(); assertThat(paths.has("/offers/{id}/remind")).isTrue(); // POST /offers 必须声明 POST 方法 assertTrue(paths.path("/offers").has("post")); // GET /offers 必须声明 GET 方法 assertTrue(paths.path("/offers").has("get")); // 开发辅助:当 -Doffer.openapi.snapshot=<目录> 时导出 OpenAPI 快照到 <目录>/openapi-rc.json String snapshotDir = System.getProperty("offer.openapi.snapshot"); if (snapshotDir != null && !snapshotDir.isBlank()) { java.nio.file.Files.write( java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"), json.getBytes(StandardCharsets.UTF_8)); } } // ── Offer 进阶接口 ──────────────────────────────────────── @Test void openApi_shouldContainNewOfferEndpoints() throws Exception { String json = mockMvc.perform(get("/v3/api-docs")) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); JsonNode root = mapper.readTree(json); JsonNode paths = root.path("paths"); assertThat(paths.has("/offers/{id}/counter-offers")).isTrue(); assertThat(paths.has("/offers/{id}/counter-offers/{coId}/accept")).isTrue(); assertThat(paths.has("/offers/{id}/counter-offers/{coId}/reject")).isTrue(); assertThat(paths.has("/offers/compare")).isTrue(); assertThat(paths.has("/offers/statistics")).isTrue(); } @Test void createCounterOffer_shouldReturn200() throws Exception { Map coView = Map.of("id", 1L, "offerId", OFFER_ID, "status", "PENDING"); when(offerService.toCounterOfferView(any())).thenReturn(coView); when(offerService.createCounterOffer(eq(STUDENT_ID), eq(OFFER_ID), any(), any())) .thenReturn(OfferCounterOffer.builder().id(1L).build()); mockMvc.perform(post("/offers/" + OFFER_ID + "/counter-offers") .header("Authorization", studentToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"proposedSalaryMin\":15000,\"proposedSalaryMax\":25000,\"reason\":\"期望薪资更高\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.status").value("PENDING")); } @Test void createCounterOffer_notNegotiable_shouldReturn400() throws Exception { when(offerService.createCounterOffer(eq(STUDENT_ID), eq(OFFER_ID), any(), any())) .thenThrow(new BusinessException(ErrorCode.OFFER_NOT_NEGOTIABLE)); mockMvc.perform(post("/offers/" + OFFER_ID + "/counter-offers") .header("Authorization", studentToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"proposedSalaryMin\":15000,\"proposedSalaryMax\":25000}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(ErrorCode.OFFER_NOT_NEGOTIABLE.getCode())); } @Test void listCounterOffers_shouldReturn200() throws Exception { when(offerService.getCounterOffers(OFFER_ID, STUDENT_ID)).thenReturn(List.of()); mockMvc.perform(get("/offers/" + OFFER_ID + "/counter-offers") .header("Authorization", studentToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); } @Test void acceptCounterOffer_shouldReturn200() throws Exception { Map coView = Map.of("id", 1L, "status", "ACCEPTED"); when(offerService.toCounterOfferView(any())).thenReturn(coView); when(offerService.acceptCounterOffer(eq(COMPANY_USER_ID), eq(OFFER_ID), eq(1L), any())) .thenReturn(OfferCounterOffer.builder().id(1L).build()); mockMvc.perform(patch("/offers/" + OFFER_ID + "/counter-offers/1/accept") .header("Authorization", companyToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.status").value("ACCEPTED")); } @Test void acceptCounterOffer_notPending_shouldReturn409() throws Exception { when(offerService.acceptCounterOffer(eq(COMPANY_USER_ID), eq(OFFER_ID), eq(1L), any())) .thenThrow(new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING)); mockMvc.perform(patch("/offers/" + OFFER_ID + "/counter-offers/1/accept") .header("Authorization", companyToken())) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING.getCode())); } @Test void rejectCounterOffer_shouldReturn200() throws Exception { Map coView = Map.of("id", 1L, "status", "REJECTED"); when(offerService.toCounterOfferView(any())).thenReturn(coView); when(offerService.rejectCounterOffer(eq(COMPANY_USER_ID), eq(OFFER_ID), eq(1L), any())) .thenReturn(OfferCounterOffer.builder().id(1L).build()); mockMvc.perform(patch("/offers/" + OFFER_ID + "/counter-offers/1/reject") .header("Authorization", companyToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.status").value("REJECTED")); } @Test void compareOffers_shouldReturn200() throws Exception { Map v = new java.util.HashMap<>(offerView()); v.put("companyName", "测试公司"); when(offerService.compareOffers(eq(STUDENT_ID), eq(List.of(1L, 2L)))) .thenReturn(List.of(v, v)); mockMvc.perform(get("/offers/compare?ids=1,2") .header("Authorization", studentToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data[0].companyName").value("测试公司")); } @Test void compareOffers_tooMany_shouldReturn400() throws Exception { when(offerService.compareOffers(eq(STUDENT_ID), any())) .thenThrow(new BusinessException(ErrorCode.OFFER_COMPARE_TOO_MANY)); mockMvc.perform(get("/offers/compare?ids=1") .header("Authorization", studentToken())) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(ErrorCode.OFFER_COMPARE_TOO_MANY.getCode())); } @Test void compareOffers_nonNumeric_shouldReturn400() throws Exception { mockMvc.perform(get("/offers/compare?ids=abc") .header("Authorization", studentToken())) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); } @Test void compareOffers_emptyValue_shouldReturn400() throws Exception { mockMvc.perform(get("/offers/compare?ids=1,,2") .header("Authorization", studentToken())) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); } @Test void compareOffers_blank_shouldReturn400() throws Exception { mockMvc.perform(get("/offers/compare?ids=") .header("Authorization", studentToken())) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); } @Test void compareOffers_negative_shouldReturn400() throws Exception { mockMvc.perform(get("/offers/compare?ids=1,-2") .header("Authorization", studentToken())) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); } @Test void statistics_shouldReturn200() throws Exception { when(offerService.getStatistics(COMPANY_USER_ID, 6)) .thenReturn(Map.of("totalOffers", 10, "acceptanceRate", 0.6)); mockMvc.perform(get("/offers/statistics") .header("Authorization", companyToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.totalOffers").value(10)); } @Test void statistics_withMonths_shouldReturn200() throws Exception { when(offerService.getStatistics(COMPANY_USER_ID, 12)) .thenReturn(Map.of("totalOffers", 5, "periodMonths", 12)); mockMvc.perform(get("/offers/statistics?months=12") .header("Authorization", companyToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.periodMonths").value(12)); } }