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