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
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());
|
|
}
|
|
}
|