package com.mydreamjob.bidirectionalmatch; 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.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.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; 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.*; /** * 双向匹配接口集成测试:JWT、统一响应、权限、校验、OpenAPI 一致性。 * 激活 profile: integration-test(H2 内嵌数据库 + Flyway V16) */ @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") class ApplicationMatchControllerTest { @Autowired private MockMvc mockMvc; @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; @MockBean private BidirectionalMatchService matchService; @MockBean private MatchRuleService matchRuleService; @MockBean private MatchConversationService matchConversationService; @MockBean private RedisTemplate redisTemplate; private final ObjectMapper mapper = new ObjectMapper(); private static final Long STUDENT_ID = 100L; private static final Long COMPANY_ID = 10L; private static final Long APPLICATION_ID = 5L; @BeforeEach @SuppressWarnings("unchecked") void setUp() { ValueOperations ops = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(any())).thenReturn(false); } private String studentToken() { return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT"); } private String companyToken() { return com.mydreamjob.auth.support.TestAuthTokens.bearer(COMPANY_ID, "COMPANY"); } private ApplicationMatchRecord record() { return ApplicationMatchRecord.builder().id(1L).applicationId(APPLICATION_ID) .jobId(10L).companyId(1L).candidateId(STUDENT_ID) .candidateInterestScore(80).hrSuitabilityScore(80) .companyRequiredCandidateScore(60).studentRequiredHrScore(60) .status(ApplicationMatchRecord.STATUS_MATCHED).build(); } // ── 阈值 ────────────────────────────────────────────────── @Test void getCompanyRule_shouldReturn200() throws Exception { when(matchRuleService.getCompanyRule(COMPANY_ID)).thenReturn(Map.of("companyId", 1L, "requiredCandidateScore", 60)); mockMvc.perform(get("/companies/me/match-rule").header("Authorization", companyToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.requiredCandidateScore").value(60)); } @Test void putCompanyRule_shouldReturn200() throws Exception { when(matchRuleService.updateCompanyRule(eq(COMPANY_ID), eq(70))).thenReturn(Map.of("requiredCandidateScore", 70)); mockMvc.perform(put("/companies/me/match-rule") .header("Authorization", companyToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"requiredCandidateScore\":70}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.requiredCandidateScore").value(70)); } @Test void putCompanyRule_invalidScore_shouldReturn400() throws Exception { mockMvc.perform(put("/companies/me/match-rule") .header("Authorization", companyToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"requiredCandidateScore\":101}")) .andExpect(status().isBadRequest()); } @Test void getStudentRule_shouldReturn200() throws Exception { when(matchRuleService.getStudentRule(STUDENT_ID)).thenReturn(Map.of("studentUserId", STUDENT_ID, "requiredHrScore", 60)); mockMvc.perform(get("/profile/me/match-rule").header("Authorization", studentToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); } @Test void putStudentRule_shouldReturn200() throws Exception { when(matchRuleService.updateStudentRule(eq(STUDENT_ID), eq(75))).thenReturn(Map.of("requiredHrScore", 75)); mockMvc.perform(put("/profile/me/match-rule") .header("Authorization", studentToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"requiredHrScore\":75}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.requiredHrScore").value(75)); } // ── 评分 ────────────────────────────────────────────────── @Test void putCandidateInterestScore_shouldReturn200() throws Exception { when(matchService.submitCandidateInterestScore(eq(STUDENT_ID), eq(APPLICATION_ID), eq(80), any())) .thenReturn(record()); mockMvc.perform(put("/applications/" + APPLICATION_ID + "/candidate-interest-score") .header("Authorization", studentToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"score\":80}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.status").value("MATCHED")); } @Test void putCandidateInterestScore_invalidScore_shouldReturn400() throws Exception { mockMvc.perform(put("/applications/" + APPLICATION_ID + "/candidate-interest-score") .header("Authorization", studentToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"score\":150}")) .andExpect(status().isBadRequest()); } @Test void putCandidateInterestScore_asCompany_shouldReturn403() throws Exception { when(matchService.submitCandidateInterestScore(eq(COMPANY_ID), eq(APPLICATION_ID), eq(80), any())) .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); mockMvc.perform(put("/applications/" + APPLICATION_ID + "/candidate-interest-score") .header("Authorization", companyToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"score\":80}")) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); } @Test void putHrSuitabilityScore_shouldReturn200() throws Exception { when(matchService.submitHrSuitabilityScore(eq(COMPANY_ID), eq(APPLICATION_ID), eq(80), any())) .thenReturn(record()); mockMvc.perform(put("/applications/" + APPLICATION_ID + "/hr-suitability-score") .header("Authorization", companyToken()) .contentType(MediaType.APPLICATION_JSON) .content("{\"score\":80}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.candidateInterestScore").value(80)); } // ── 匹配状态 ────────────────────────────────────────────── @Test void getMatch_shouldReturn200() throws Exception { when(matchService.getMatchView(STUDENT_ID, APPLICATION_ID)).thenReturn(Map.of( "applicationId", APPLICATION_ID, "status", "MATCHED", "candidateInterestScore", 80)); mockMvc.perform(get("/applications/" + APPLICATION_ID + "/match").header("Authorization", studentToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("MATCHED")) .andExpect(jsonPath("$.data.eventId").doesNotExist()); } @Test void getMatch_noToken_shouldReturn401() throws Exception { mockMvc.perform(get("/applications/" + APPLICATION_ID + "/match")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.code").value(401)); } @Test void getMatch_notFound_shouldReturn404() throws Exception { when(matchService.getMatchView(STUDENT_ID, 999L)) .thenThrow(new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); mockMvc.perform(get("/applications/999/match").header("Authorization", studentToken())) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value(ErrorCode.APPLICATION_NOT_FOUND.getCode())); } // ── 会话解锁 ────────────────────────────────────────────── @Test void postConversation_shouldReturn200() throws Exception { when(matchConversationService.createOrGetMatchConversation(STUDENT_ID, APPLICATION_ID)) .thenReturn(Map.of("id", 88L, "type", "DIRECT")); mockMvc.perform(post("/applications/" + APPLICATION_ID + "/conversation") .header("Authorization", studentToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.id").value(88L)); } @Test void postConversation_notMatched_shouldReturn409() throws Exception { when(matchConversationService.createOrGetMatchConversation(STUDENT_ID, APPLICATION_ID)) .thenThrow(new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED)); mockMvc.perform(post("/applications/" + APPLICATION_ID + "/conversation") .header("Authorization", studentToken())) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(ErrorCode.APPLICATION_NOT_MATCHED.getCode())); } // ── OpenAPI 一致性 ──────────────────────────────────────── @Test void openApi_shouldContainMatchEndpoints() throws Exception { String json = mockMvc.perform(get("/v3/api-docs")) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); JsonNode paths = mapper.readTree(json).path("paths"); assertThat(paths.has("/companies/me/match-rule")).isTrue(); assertThat(paths.has("/profile/me/match-rule")).isTrue(); assertThat(paths.has("/applications/{id}/candidate-interest-score")).isTrue(); assertThat(paths.has("/applications/{id}/hr-suitability-score")).isTrue(); assertThat(paths.has("/applications/{id}/match")).isTrue(); assertThat(paths.has("/applications/{id}/conversation")).isTrue(); 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)); } } }