package com.mydreamjob.careerprogress;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.careerprogress.dto.CareerProgressResponse;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.interview.Interview;
import com.mydreamjob.interview.InterviewRepository;
import com.mydreamjob.interviewscore.InterviewScore;
import com.mydreamjob.interviewscore.InterviewScoreRepository;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.notification.Notification;
import com.mydreamjob.notification.NotificationRepository;
import com.mydreamjob.offer.Offer;
import com.mydreamjob.offer.OfferRepository;
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.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.context.TestPropertySource;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 统一求职进度聚合持久化/只读集成测试(V34,独立 H2 + Flyway 真实表结构)。
*
*
证明:真实批量查询在真实 Repository 上正确返回聚合结果;CareerProgressService
* 标注 @Transactional(readOnly=true) 且不写业务表 —— 调用前后关键业务表计数不变;
* 不新增 Flyway、不删除业务数据。
*/
@SpringBootTest
@TestPropertySource(properties =
"spring.datasource.url=jdbc:h2:mem:integrationdb_careerprogress_persist;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
@ActiveProfiles("integration-test")
class CareerProgressPersistenceIntegrationTest {
@Autowired private CareerProgressService service;
@Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private JobRepository jobRepository;
@Autowired private ApplicationRepository applicationRepository;
@Autowired private InterviewRepository interviewRepository;
@Autowired private InterviewScoreRepository interviewScoreRepository;
@Autowired private OfferRepository offerRepository;
@Autowired private NotificationRepository notificationRepository;
@Autowired private CandidateDimensionScoreRepository dimensionScoreRepository;
@MockBean private RedisTemplate redisTemplate;
private static final AtomicLong PHONE_SEQ = new AtomicLong(600);
private String period;
private Long studentUserId;
private Long companyUserId;
private Long companyId;
private Long jobId;
private Long appId;
@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());
studentUserId = createUser(User.Role.STUDENT);
companyUserId = createUser(User.Role.COMPANY);
Long rivalId = createUser(User.Role.STUDENT);
companyId = companyRepository.save(
Company.builder().ownerUserId(companyUserId).name("示例科技").build()).getId();
jobId = jobRepository.save(Job.builder().companyId(companyId).title("Java 后端工程师")
.description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()).getId();
Application app = applicationRepository.save(Application.builder()
.jobId(jobId).studentUserId(studentUserId).status("INTERVIEW")
.createdAt(LocalDateTime.now().minusDays(1)).updatedAt(LocalDateTime.now()).build());
appId = app.getId();
applicationRepository.save(Application.builder()
.jobId(jobId).studentUserId(rivalId).status("SUBMITTED")
.createdAt(LocalDateTime.now().minusDays(2)).updatedAt(LocalDateTime.now()).build());
// 竞争力快照
dimensionScoreRepository.save(dim(studentUserId, "77.50"));
dimensionScoreRepository.save(dim(rivalId, "90.00"));
// 面试:SCHEDULED(待响应)+ CONFIRMED(未来)
Interview sched = interviewRepository.save(interview("SCHEDULED", LocalDateTime.now().plusDays(1), 2));
interviewRepository.save(interview("CONFIRMED", LocalDateTime.now().plusDays(2), 3));
// 已提交评分(摘要口径,不返回 comment)
interviewScoreRepository.save(InterviewScore.builder()
.interviewId(sched.getId()).applicationId(appId).companyUserId(companyUserId)
.studentUserId(studentUserId).status(InterviewScore.STATUS_SUBMITTED)
.technicalScore(4).communicationScore(3).problemSolvingScore(4)
.roleFitScore(4).professionalismScore(3)
.overallScore(new BigDecimal("3.60")).recommendation("HIRE")
.comment("不应返回给学生").submittedAt(LocalDateTime.now()).version(0L).build());
// 有效 Offer
offerRepository.save(Offer.builder()
.applicationId(appId).jobId(jobId).companyId(companyId).candidateId(studentUserId)
.title("Java 后端工程师").description("d").expireAt(LocalDateTime.now().plusDays(9))
.salaryMin(20000).salaryMax(30000).baseSalary(25000)
.status("SENT").version(0L).createdBy(companyUserId).build());
// 通知:未读 2 条(其中 1 条 REPORT_DECISION)
notificationRepository.save(Notification.builder().userId(studentUserId).type("INTERVIEW_SCHEDULED")
.title("面试通知").content("你有一场面试").build());
notificationRepository.save(Notification.builder().userId(studentUserId).type("REPORT_DECISION")
.title("举报处理结果").content("举报已处理").build());
}
@AfterEach
void tearDown() {
dimensionScoreRepository.deleteAll();
interviewScoreRepository.deleteAll();
offerRepository.deleteAll();
interviewRepository.deleteAll();
notificationRepository.deleteAll();
applicationRepository.deleteAll();
jobRepository.deleteAll();
companyRepository.deleteAll();
userRepository.deleteAll();
}
@Test
void aggregation_returnsCorrectSummaries_andIsReadOnly() {
long appsBefore = applicationRepository.count();
long interviewsBefore = interviewRepository.count();
long scoresBefore = interviewScoreRepository.count();
long offersBefore = offerRepository.count();
long notificationsBefore = notificationRepository.count();
long dimsBefore = dimensionScoreRepository.count();
CareerProgressResponse resp = service.getCareerProgress(studentUserId, 0, 20, null);
// todo 计数(真实批量查询)
assertEquals(1L, resp.getTodo().getPendingInterviewResponseCount());
assertEquals(1L, resp.getTodo().getUpcomingConfirmedInterviewCount());
assertEquals(1L, resp.getTodo().getPendingOfferResponseCount());
assertEquals(2L, resp.getTodo().getUnreadNotificationCount());
assertEquals(1L, resp.getTodo().getUnreadReportDecisionCount());
// 投递分页(本人仅 1 条,不包含 rival 投递)
assertEquals(1L, resp.getApplications().getTotal());
assertEquals(1, resp.getApplications().getItems().size());
CareerProgressResponse.CompetitionSummary comp =
resp.getApplications().getItems().get(0).getCompetition();
assertTrue(comp.isEligible());
assertEquals(2, comp.getCohortSize());
assertEquals(2, comp.getRank().intValue());
assertEquals(new BigDecimal("77.50"), comp.getOverallScore());
assertEquals(new BigDecimal("0.00"), comp.getPercentile());
assertEquals(period, comp.getMySnapshotPeriod());
assertNotNull(resp.getGeneratedAt());
// 只读:调用前后关键业务表计数完全不变(不写、不删、不新增)
assertEquals(appsBefore, applicationRepository.count());
assertEquals(interviewsBefore, interviewRepository.count());
assertEquals(scoresBefore, interviewScoreRepository.count());
assertEquals(offersBefore, offerRepository.count());
assertEquals(notificationsBefore, notificationRepository.count());
assertEquals(dimsBefore, dimensionScoreRepository.count());
}
@Test
void withdrawnApplication_keptInHistory_competitionNotEligible() {
// 独立岗位避免 (job_id, student_user_id) 唯一约束冲突
Long withdrawnJobId = jobRepository.save(Job.builder().companyId(companyId).title("已撤回岗位")
.description("d").city("上海").employmentType("FULL_TIME").status("PUBLISHED").build()).getId();
applicationRepository.save(Application.builder()
.jobId(withdrawnJobId).studentUserId(studentUserId).status("WITHDRAWN")
.createdAt(LocalDateTime.now().minusDays(3)).updatedAt(LocalDateTime.now()).build());
CareerProgressResponse resp = service.getCareerProgress(studentUserId, 0, 20, null);
// 现在本人有两条投递(INTERVIEW + WITHDRAWN)
assertEquals(2L, resp.getApplications().getTotal());
CareerProgressResponse.CareerProgressItem withdrawn = resp.getApplications().getItems().stream()
.filter(i -> "WITHDRAWN".equals(i.getApplicationStatus())).findFirst().orElseThrow();
assertEquals("WITHDRAWN", withdrawn.getApplicationStatus());
assertEquals(false, withdrawn.getCompetition().isEligible());
assertEquals(null, withdrawn.getCompetition().getRank());
assertEquals(null, withdrawn.getCompetition().getCohortSize());
assertEquals(null, withdrawn.getCompetition().getOverallScore());
assertEquals(null, withdrawn.getCompetition().getPercentile());
}
private Long createUser(User.Role role) {
return userRepository.save(User.builder()
.phone("168" + String.format("%08d", PHONE_SEQ.incrementAndGet()))
.nickname("u").role(role).roles(Set.of(role)).active(true).verified(true).build()).getId();
}
private Interview interview(String status, LocalDateTime startAt, int roundNo) {
return Interview.builder().applicationId(appId).companyUserId(companyUserId).studentUserId(studentUserId)
.roundNo(roundNo).title("技术面试").interviewType("ONLINE").startAt(startAt)
.endAt(startAt.plusHours(1)).status(status).build();
}
private CandidateDimensionScore dim(Long sid, String score) {
return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(period)
.score(new BigDecimal(score)).status("OK").weight(new BigDecimal("12.50"))
.rulesVersion("candidate-scoring-v0.1").calculatedAt(LocalDateTime.now()).build();
}
}