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.
 
 
 

325 lines
14 KiB

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)。
*
* <p>覆盖权限矩阵、cohort(WITHDRAWN/REJECTED 排除)、排序、百分位、Top5 脱敏、
* 无分候选人为 null、岗位状态与参数校验。身份仅取 JWT。</p>
*/
@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<String, Object> 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<String, Object> 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());
}
}