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.
 
 
 

470 lines
23 KiB

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<String, Object> 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<String, Object> 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<String, Object> 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<Offer> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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));
}
}