package com.mydreamjob.competition; import com.mydreamjob.application.Application; import com.mydreamjob.application.ApplicationRepository; import com.mydreamjob.auth.JwtTokenProvider; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobRepository; import org.junit.jupiter.api.AfterEach; 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.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * 同岗位竞争力排名控制器集成测试(V32,真实 Service + H2)。 * *

覆盖权限矩阵、cohort(WITHDRAWN/REJECTED 排除)、排序、百分位、Top5 脱敏、 * 无分候选人为 null、岗位状态与参数校验。身份仅取 JWT。

*/ @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") class CompetitionRankingControllerTest { @Autowired private MockMvc mockMvc; @Autowired private JwtTokenProvider jwtTokenProvider; @Autowired private UserRepository userRepository; @Autowired private CompanyRepository companyRepository; @Autowired private JobRepository jobRepository; @Autowired private ApplicationRepository applicationRepository; @Autowired private CandidateDimensionScoreRepository scoreRepository; @MockBean private RedisTemplate redisTemplate; private static final AtomicLong PHONE = new AtomicLong(900); private Long me; // 已投 job1 且有分 private Long a; // 90 private Long b; // 80 private Long c; // 无分 private Long w; // WITHDRAWN(排除) private Long r; // REJECTED(排除) private Long noScoreStudent; // job2 无分本人 private Long d; // 95 private Long e; // 80 private Long companyUserId; private Long adminUserId; private Long job1; // PUBLISHED private Long job2; // PUBLISHED private Long draftJob; private Long closedJob; private String period; @BeforeEach @SuppressWarnings("unchecked") void setUp() { ValueOperations ops = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(anyString())).thenReturn(false); period = String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue()); me = createUser("me"); a = createUser("A"); b = createUser("B"); c = createUser("C"); w = createUser("W"); r = createUser("R"); noScoreStudent = createUser("NSA"); d = createUser("D"); e = createUser("E"); companyUserId = createUser("CC"); adminUserId = createUser("ADM"); // 角色覆盖:所有求职者为 STUDENT,companyUser=COMPANY,admin=ADMIN setRole(me, User.Role.STUDENT); setRole(a, User.Role.STUDENT); setRole(b, User.Role.STUDENT); setRole(c, User.Role.STUDENT); setRole(w, User.Role.STUDENT); setRole(r, User.Role.STUDENT); setRole(noScoreStudent, User.Role.STUDENT); setRole(d, User.Role.STUDENT); setRole(e, User.Role.STUDENT); Long companyId = companyRepository.save( Company.builder().ownerUserId(companyUserId).name("竞争力企业").build()).getId(); job1 = newJob(companyId, "后端", "PUBLISHED"); job2 = newJob(companyId, "前端", "PUBLISHED"); draftJob = newJob(companyId, "草稿岗", "DRAFT"); closedJob = newJob(companyId, "关闭岗", "CLOSED"); // job1 cohort:me/A/B 有分,C 无分;W(排除)、R(排除) apply(job1, me, "SUBMITTED"); apply(job1, a, "SUBMITTED"); apply(job1, b, "VIEWED"); apply(job1, c, "SUBMITTED"); apply(job1, w, "WITHDRAWN"); apply(job1, r, "REJECTED"); score(me, new BigDecimal("85.00")); score(a, new BigDecimal("90.00")); score(b, new BigDecimal("80.00")); // job2:本人无分,D/E 有分 apply(job2, noScoreStudent, "SUBMITTED"); apply(job2, d, "SUBMITTED"); apply(job2, e, "SUBMITTED"); score(d, new BigDecimal("95.00")); score(e, new BigDecimal("80.00")); } @AfterEach void tearDown() { scoreRepository.deleteAll(); applicationRepository.deleteAll(); jobRepository.deleteAll(); companyRepository.deleteAll(); userRepository.deleteAll(); } // ── 辅助 ──────────────────────────────────────────────── private Long createUser(String tag) { User u = userRepository.save(User.builder() .phone("139" + String.format("%08d", PHONE.incrementAndGet())) .nickname(tag).role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)) .active(true).verified(true).build()); return u.getId(); } private void setRole(Long uid, User.Role role) { User u = userRepository.findById(uid).orElseThrow(); u.setRoles(Set.of(role)); u.setRole(role); userRepository.save(u); } private Long newJob(Long companyId, String title, String status) { return jobRepository.save(Job.builder().companyId(companyId).title(title).description("d") .city("杭州").employmentType("FULL_TIME").status(status).build()).getId(); } private void apply(Long jobId, Long studentId, String status) { applicationRepository.save(Application.builder().jobId(jobId).studentUserId(studentId) .status(status).build()); } private void score(Long sid, BigDecimal overall) { scoreRepository.save(CandidateDimensionScore.builder().studentUserId(sid) .dimension("EDUCATION").period(period).score(overall).status("OK") .weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1") .calculatedAt(LocalDateTime.now()).build()); } private String token(Long userId) { return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } // ── 权限矩阵 ────────────────────────────────────────────── @Test void unauthenticated_returns401() throws Exception { mockMvc.perform(get("/jobs/{id}/competition-ranking", job1)) .andExpect(status().isUnauthorized()); } @Test void company_returns403_2002() throws Exception { mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) .header("Authorization", tokenWithRole(companyUserId, User.Role.COMPANY))) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(2002)); } @Test void admin_returns403_2002() throws Exception { mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) .header("Authorization", tokenWithRole(adminUserId, User.Role.ADMIN))) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(2002)); } @Test void studentNotApplied_returns403_2002() throws Exception { // c 已投 job1,用未投 job1 的 D 学生验证 mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) .header("Authorization", token(d))) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(2002)); } // 唯一投递为 WITHDRAWN / REJECTED → 已退出候选人池 → 403/2002 @Test void withdrawnStudent_returns403_2002() throws Exception { mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) .header("Authorization", token(w))) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(2002)); } @Test void rejectedStudent_returns403_2002() throws Exception { mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) .header("Authorization", token(r))) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value(2002)); } @Test void jobIdNonPositive_returns400_1001() throws Exception { mockMvc.perform(get("/jobs/{id}/competition-ranking", 0) .header("Authorization", token(me))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(1001)); } @Test void jobNotExists_returns404_7001() throws Exception { mockMvc.perform(get("/jobs/{id}/competition-ranking", 999999L) .header("Authorization", token(me))) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value(7001)); } @Test void draftJob_returns404_7001() throws Exception { mockMvc.perform(get("/jobs/{id}/competition-ranking", draftJob) .header("Authorization", token(me))) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value(7001)); } @Test void closedJob_returns404_7001() throws Exception { mockMvc.perform(get("/jobs/{id}/competition-ranking", closedJob) .header("Authorization", token(me))) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value(7001)); } // ── 排名 / cohort / 百分位 / 脱敏 ────────────────────────── @Test void appliedStudent_returns200_rank_andPercentile_andDesensitizedTop5() throws Exception { mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) .header("Authorization", token(me))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.jobId").value(job1)) // cohort = me,A,B,C(WITHDRAWN/REJECTED 被排除),C 计入 cohortSize .andExpect(jsonPath("$.data.cohortSize").value(4)) .andExpect(jsonPath("$.data.rank").value(2)) .andExpect(jsonPath("$.data.overallScore").value(85.00)) .andExpect(jsonPath("$.data.percentile").value(50.00)) // lower=1/denom=2 .andExpect(jsonPath("$.data.status").value("OK")) .andExpect(jsonPath("$.data.asOfPeriod").value(period)) .andExpect(jsonPath("$.data.mySnapshotPeriod").value(period)) .andExpect(jsonPath("$.data.topCandidates.length()").value(4)) .andExpect(jsonPath("$.data.topCandidates[0].rank").value(1)) .andExpect(jsonPath("$.data.topCandidates[0].overallScore").value(90.00)) // Top5 脱敏:不返回任何身份字段 .andExpect(jsonPath("$.data.topCandidates[0].studentUserId").doesNotExist()) .andExpect(jsonPath("$.data.topCandidates[0].candidateId").doesNotExist()) .andExpect(jsonPath("$.data.topCandidates[0].name").doesNotExist()) .andExpect(jsonPath("$.data.topCandidates[0].school").doesNotExist()) .andExpect(jsonPath("$.data.topCandidates[0].phone").doesNotExist()) .andExpect(jsonPath("$.data.topCandidates[0].email").doesNotExist()) .andExpect(jsonPath("$.data.topCandidates[0].applicationId").doesNotExist()) // 响应整体也不出现 candidateId/studentUserId 顶层字段 .andExpect(jsonPath("$.data.candidateId").doesNotExist()) .andExpect(jsonPath("$.data.studentUserId").doesNotExist()); } @Test void withdrawnRejected_notInCohort() throws Exception { // w/r 虽被投递但状态为 WITHDRAWN/REJECTED,cohort 仍为 4(me,A,B,C) mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) .header("Authorization", token(me))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.cohortSize").value(4)) .andExpect(jsonPath("$.data.topCandidates.length()").value(4)); } @Test void currentStudentNoScore_rankScorePercentileNull() throws Exception { mockMvc.perform(get("/jobs/{id}/competition-ranking", job2) .header("Authorization", token(noScoreStudent))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.cohortSize").value(3)) .andExpect(jsonPath("$.data.rank").value(org.hamcrest.Matchers.nullValue())) .andExpect(jsonPath("$.data.overallScore").value(org.hamcrest.Matchers.nullValue())) .andExpect(jsonPath("$.data.percentile").value(org.hamcrest.Matchers.nullValue())) .andExpect(jsonPath("$.data.mySnapshotPeriod").value(org.hamcrest.Matchers.nullValue())); } private String tokenWithRole(Long userId, User.Role role) { return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } }