package com.mydreamjob.review; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobRepository; import com.mydreamjob.job.JobService; import com.mydreamjob.review.dto.AdminCompanyReviewReplyResponse; import com.mydreamjob.review.dto.CompanyReviewReplyCreateRequest; import com.mydreamjob.review.dto.CompanyReviewReplyUpdateRequest; import com.mydreamjob.review.dto.ReviewReplyStatsResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class CompanyReviewReplyServiceTest { @Mock private CompanyReviewReplyRepository replyRepository; @Mock private JobReviewRepository reviewRepository; @Mock private JobRepository jobRepository; @Mock private JobService jobService; @Mock private CompanyRepository companyRepository; @Mock private UserRepository userRepository; @InjectMocks private CompanyReviewReplyService replyService; private static final Long COMPANY_USER_ID = 10L; // 企业账号 userId private static final Long COMPANY_ID = 20L; // 企业 id private static final Long OTHER_COMPANY_USER_ID = 11L; // 其他企业账号 userId private static final Long OTHER_COMPANY_ID = 21L; // 其他企业 id private static final Long REVIEW_ID = 100L; private static final Long JOB_ID = 50L; private static final Long ADMIN_USER_ID = 1L; @BeforeEach void setUp() { // 默认:adminId 为 ADMIN(lenient 避免未用到的用例报 UnnecessaryStubbing) lenient().when(userRepository.findById(ADMIN_USER_ID)) .thenReturn(Optional.of(adminUser())); } private User adminUser() { return User.builder().id(ADMIN_USER_ID).role(User.Role.ADMIN) .roles(Set.of(User.Role.ADMIN)).build(); } private User normalUser(Long id) { return User.builder().id(id).role(User.Role.COMPANY) .roles(Set.of(User.Role.COMPANY)).build(); } private JobReview approvedReview() { return JobReview.builder() .id(REVIEW_ID).jobId(JOB_ID).studentUserId(999L) .rating(4).content("Great job") .moderationStatus(JobReview.MODERATION_AUTO_APPROVED) .build(); } private void mockCompanyOwnership(Long userId, Long companyId) { when(companyRepository.findByOwnerUserId(userId)) .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).build())); } private void stubJobWithCompany(Long companyId) { when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(companyId).title("Java").build()); } private CompanyReviewReplyCreateRequest buildCreateReq() { CompanyReviewReplyCreateRequest req = new CompanyReviewReplyCreateRequest(); req.setContent("感谢您的反馈,我们会持续改进"); return req; } // ── 创建(企业归属校验是核心)────────────────────────────── @Test void createReply_companyOwnsJob_shouldSucceed() { when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); stubJobWithCompany(COMPANY_ID); mockCompanyOwnership(COMPANY_USER_ID, COMPANY_ID); when(replyRepository.existsByReviewId(REVIEW_ID)).thenReturn(false); when(replyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); var r = replyService.createReply(COMPANY_USER_ID, REVIEW_ID, buildCreateReq()); assertEquals(CompanyReviewReply.MODERATION_PENDING, r.getModerationStatus()); assertEquals("感谢您的反馈,我们会持续改进", r.getContent()); verify(replyRepository).save(any()); } @Test void createReply_otherCompany_shouldReturn403() { when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); stubJobWithCompany(COMPANY_ID); mockCompanyOwnership(OTHER_COMPANY_USER_ID, OTHER_COMPANY_ID); BusinessException ex = assertThrows(BusinessException.class, () -> replyService.createReply(OTHER_COMPANY_USER_ID, REVIEW_ID, buildCreateReq())); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } @Test void createReply_reviewNotApproved_shouldReturn404() { JobReview pending = approvedReview(); pending.setModerationStatus(JobReview.MODERATION_PENDING); when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(pending)); BusinessException ex = assertThrows(BusinessException.class, () -> replyService.createReply(COMPANY_USER_ID, REVIEW_ID, buildCreateReq())); assertEquals(ErrorCode.REVIEW_NOT_FOUND.getCode(), ex.getErrorCodeInt()); } @Test void createReply_duplicate_shouldReturn409() { when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); stubJobWithCompany(COMPANY_ID); mockCompanyOwnership(COMPANY_USER_ID, COMPANY_ID); when(replyRepository.existsByReviewId(REVIEW_ID)).thenReturn(true); BusinessException ex = assertThrows(BusinessException.class, () -> replyService.createReply(COMPANY_USER_ID, REVIEW_ID, buildCreateReq())); assertEquals(ErrorCode.DUPLICATE_REPLY.getCode(), ex.getErrorCodeInt()); } @Test void createReply_companyUserWithoutOwnedCompany_shouldReturn403() { when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); stubJobWithCompany(COMPANY_ID); // companyRepository 不 stub -> 默认空 -> 无归属企业 BusinessException ex = assertThrows(BusinessException.class, () -> replyService.createReply(COMPANY_USER_ID, REVIEW_ID, buildCreateReq())); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } // ── 查询 ───────────────────────────────────────────────── @Test void getReply_approved_shouldSucceed() { CompanyReviewReply reply = CompanyReviewReply.builder() .id(1L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID) .content("回复内容").moderationStatus(CompanyReviewReply.MODERATION_APPROVED) .createdAt(LocalDateTime.now()).build(); when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); var r = replyService.getReply(null, REVIEW_ID); assertEquals("回复内容", r.getContent()); } @Test void getReply_pending_otherUser_shouldReturn404() { CompanyReviewReply reply = CompanyReviewReply.builder() .id(1L).reviewId(REVIEW_ID).content("回复内容") .moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); BusinessException ex = assertThrows(BusinessException.class, () -> replyService.getReply(999L, REVIEW_ID)); assertEquals(ErrorCode.REPLY_NOT_FOUND.getCode(), ex.getErrorCodeInt()); } @Test void getReply_pending_anonymous_shouldReturn404() { CompanyReviewReply reply = CompanyReviewReply.builder() .id(1L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID).content("回复内容") .moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); // 匿名请求:currentUserIdOrNull() 返回 null(原为把匿名 token 当 ADMIN 解析导致 500) BusinessException ex = assertThrows(BusinessException.class, () -> replyService.getReply(null, REVIEW_ID)); assertEquals(ErrorCode.REPLY_NOT_FOUND.getCode(), ex.getErrorCodeInt()); } @Test void getReply_pending_owner_shouldSucceedWithPending() { CompanyReviewReply reply = CompanyReviewReply.builder() .id(1L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID) .content("回复内容").moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); var r = replyService.getReply(COMPANY_USER_ID, REVIEW_ID); assertEquals(CompanyReviewReply.MODERATION_PENDING, r.getModerationStatus()); } @Test void getReply_rejected_owner_shouldSucceedWithReason() { CompanyReviewReply reply = CompanyReviewReply.builder() .id(1L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID) .content("回复内容").moderationStatus(CompanyReviewReply.MODERATION_REJECTED) .moderationReason("内容违规").build(); when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); var r = replyService.getReply(COMPANY_USER_ID, REVIEW_ID); assertEquals(CompanyReviewReply.MODERATION_REJECTED, r.getModerationStatus()); assertEquals("内容违规", r.getModerationReason()); } @Test void batchGetApprovedReplies_shouldFilterApproved() { CompanyReviewReply approved = CompanyReviewReply.builder() .id(1L).reviewId(REVIEW_ID).content("OK") .moderationStatus(CompanyReviewReply.MODERATION_APPROVED).build(); CompanyReviewReply pending = CompanyReviewReply.builder() .id(2L).reviewId(101L).content("P") .moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); // Service 使用 findByReviewIdInAndModerationStatus(ids, APPROVED),只返回 approved when(replyRepository.findByReviewIdInAndModerationStatus(anyList(), eq(CompanyReviewReply.MODERATION_APPROVED))) .thenReturn(java.util.List.of(approved)); var map = replyService.batchGetApprovedReplies(java.util.List.of(REVIEW_ID, 101L)); assertEquals(1, map.size()); assertTrue(map.containsKey(REVIEW_ID)); assertFalse(map.containsKey(101L)); } // ── 更新(企业侧)───────────────────────────────────────── @Test void updateReply_companyOwns_shouldResetToPending() { CompanyReviewReply reply = CompanyReviewReply.builder() .id(1L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID) .content("旧内容").moderationStatus(CompanyReviewReply.MODERATION_APPROVED).build(); when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); stubJobWithCompany(COMPANY_ID); mockCompanyOwnership(COMPANY_USER_ID, COMPANY_ID); when(replyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); CompanyReviewReplyUpdateRequest req = new CompanyReviewReplyUpdateRequest(); req.setContent("新内容"); var r = replyService.updateReply(COMPANY_USER_ID, REVIEW_ID, req); assertEquals("新内容", r.getContent()); assertEquals(CompanyReviewReply.MODERATION_PENDING, r.getModerationStatus()); } @Test void updateReply_otherCompany_shouldReturn403() { CompanyReviewReply reply = CompanyReviewReply.builder() .id(1L).reviewId(REVIEW_ID).content("旧内容").build(); when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); stubJobWithCompany(COMPANY_ID); mockCompanyOwnership(OTHER_COMPANY_USER_ID, OTHER_COMPANY_ID); CompanyReviewReplyUpdateRequest req = new CompanyReviewReplyUpdateRequest(); req.setContent("新内容"); BusinessException ex = assertThrows(BusinessException.class, () -> replyService.updateReply(OTHER_COMPANY_USER_ID, REVIEW_ID, req)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } // ── 删除(企业侧)───────────────────────────────────────── @Test void deleteReply_companyOwns_shouldSucceed() { CompanyReviewReply reply = CompanyReviewReply.builder().id(1L).reviewId(REVIEW_ID).build(); when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); stubJobWithCompany(COMPANY_ID); mockCompanyOwnership(COMPANY_USER_ID, COMPANY_ID); assertDoesNotThrow(() -> replyService.deleteReply(COMPANY_USER_ID, REVIEW_ID)); verify(replyRepository).delete(reply); } @Test void deleteReply_otherCompany_shouldReturn403() { CompanyReviewReply reply = CompanyReviewReply.builder().id(1L).reviewId(REVIEW_ID).build(); when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); stubJobWithCompany(COMPANY_ID); mockCompanyOwnership(OTHER_COMPANY_USER_ID, OTHER_COMPANY_ID); BusinessException ex = assertThrows(BusinessException.class, () -> replyService.deleteReply(OTHER_COMPANY_USER_ID, REVIEW_ID)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } // ── 管理员审核 ──────────────────────────────────────────── @Test void moderateReply_approve_shouldSucceed() { CompanyReviewReply reply = CompanyReviewReply.builder() .id(1L).reviewId(REVIEW_ID).content("内容") .moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); when(replyRepository.findById(1L)).thenReturn(Optional.of(reply)); when(replyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); var r = replyService.moderateReply(ADMIN_ROLE_ID(), 1L, CompanyReviewReply.MODERATION_APPROVED, "合规"); assertEquals(CompanyReviewReply.MODERATION_APPROVED, r.getModerationStatus()); } @Test void moderateReply_reject_shouldSucceed() { CompanyReviewReply reply = CompanyReviewReply.builder() .id(1L).reviewId(REVIEW_ID).content("内容") .moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); when(replyRepository.findById(1L)).thenReturn(Optional.of(reply)); when(replyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); var r = replyService.moderateReply(ADMIN_ROLE_ID(), 1L, CompanyReviewReply.MODERATION_REJECTED, "违规"); assertEquals(CompanyReviewReply.MODERATION_REJECTED, r.getModerationStatus()); } @Test void moderateReply_alreadyDecided_shouldReturn409() { CompanyReviewReply reply = CompanyReviewReply.builder() .id(1L).reviewId(REVIEW_ID).content("内容") .moderationStatus(CompanyReviewReply.MODERATION_APPROVED).build(); when(replyRepository.findById(1L)).thenReturn(Optional.of(reply)); BusinessException ex = assertThrows(BusinessException.class, () -> replyService.moderateReply(ADMIN_ROLE_ID(), 1L, CompanyReviewReply.MODERATION_APPROVED, "x")); assertEquals(ErrorCode.REPLY_ALREADY_DECIDED.getCode(), ex.getErrorCodeInt()); } @Test void moderateReply_nonAdmin_shouldReturnForbidden() { Long nonAdminId = OTHER_COMPANY_USER_ID; when(userRepository.findById(nonAdminId)).thenReturn(Optional.of(normalUser(nonAdminId))); BusinessException ex = assertThrows(BusinessException.class, () -> replyService.moderateReply(nonAdminId, 1L, CompanyReviewReply.MODERATION_APPROVED, "x")); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } // ── 管理端回复列表(仅 ADMIN) ────────────────────────────── @Test void listAdminReplies_nonAdmin_shouldReturnForbidden() { Long nonAdminId = OTHER_COMPANY_USER_ID; when(userRepository.findById(nonAdminId)).thenReturn(Optional.of(normalUser(nonAdminId))); BusinessException ex = assertThrows(BusinessException.class, () -> replyService.listAdminReplies(nonAdminId, null, null, null, null, null, 0, 20)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } @Test void listAdminReplies_invalidStatus_shouldReturnBadRequest() { BusinessException ex = assertThrows(BusinessException.class, () -> replyService.listAdminReplies(ADMIN_USER_ID, null, null, "UNKNOWN", null, null, 0, 20)); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); } @Test void listAdminReplies_invalidPage_shouldReturnBadRequest() { assertThrows(BusinessException.class, () -> replyService.listAdminReplies(ADMIN_USER_ID, null, null, null, null, null, -1, 20)); assertThrows(BusinessException.class, () -> replyService.listAdminReplies(ADMIN_USER_ID, null, null, null, null, null, 0, 0)); assertThrows(BusinessException.class, () -> replyService.listAdminReplies(ADMIN_USER_ID, null, null, null, null, null, 0, 101)); } @Test void listAdminReplies_defaultStatusPending_andDelegatesFilters() { CompanyReviewReply reply = CompanyReviewReply.builder() .id(3L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID) .content("回复").moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); when(replyRepository.findAdminReplies(any(), any(), any(), any(), any(), any())) .thenReturn(new PageImpl<>(List.of(reply))); when(reviewRepository.findAllById(anyCollection())).thenReturn(List.of()); Page result = replyService.listAdminReplies(ADMIN_USER_ID, COMPANY_ID, JOB_ID, null, null, null, 0, 20); assertEquals(1, result.getTotalElements()); assertEquals(CompanyReviewReply.MODERATION_PENDING, result.getContent().get(0).getModerationStatus()); verify(replyRepository).findAdminReplies(eq(COMPANY_ID), eq(JOB_ID), eq(CompanyReviewReply.MODERATION_PENDING), any(), any(), any()); } @Test void listAdminReplies_enrichesJobAndCompany() { CompanyReviewReply reply = CompanyReviewReply.builder() .id(3L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID) .content("回复").moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); JobReview review = JobReview.builder().id(REVIEW_ID).jobId(JOB_ID) .studentUserId(2L).rating(3).content("评价内容").build(); Job job = Job.builder().id(JOB_ID).companyId(COMPANY_ID).title("Java 后端工程师").build(); Company company = Company.builder().id(COMPANY_ID).name("本源科技").build(); when(replyRepository.findAdminReplies(any(), any(), any(), any(), any(), any())) .thenReturn(new PageImpl<>(List.of(reply))); when(reviewRepository.findAllById(anyCollection())).thenReturn(List.of(review)); when(jobRepository.findAllById(anyCollection())).thenReturn(List.of(job)); when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company)); Page result = replyService.listAdminReplies(ADMIN_USER_ID, null, null, null, null, null, 0, 20); AdminCompanyReviewReplyResponse v = result.getContent().get(0); assertEquals("Java 后端工程师", v.getJobTitle()); assertEquals("本源科技", v.getCompanyName()); assertEquals(3, v.getReviewRating()); assertEquals(2L, v.getStudentUserId()); assertEquals(COMPANY_USER_ID, v.getCompanyUserId()); } // ── 回复率统计(企业侧) ────────────────────────────────── @Test void getReviewReplyStats_shouldComputeMonthRateAndIndustryAvg() { when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) .thenReturn(Optional.of(Company.builder().id(COMPANY_ID) .ownerUserId(COMPANY_USER_ID).industry("IT").build())); when(replyRepository.findMonthlyReplyStats(any(), any(), any(), any())).thenReturn(List.of( new Object[]{COMPANY_ID, 5L, 4L}, // 当前企业:80% new Object[]{OTHER_COMPANY_ID, 4L, 2L})); // 同行企业:50% ReviewReplyStatsResponse r = replyService.getReviewReplyStats(COMPANY_USER_ID); assertEquals(80, r.getMonthReplyRate()); assertEquals(65, r.getIndustryAvgReplyRate()); // (0.8+0.5)/2 = 0.65 assertEquals(1, r.getUnrepliedCount()); } @Test void getReviewReplyStats_noReplies_shouldReturnZeroRate() { when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) .thenReturn(Optional.of(Company.builder().id(COMPANY_ID) .ownerUserId(COMPANY_USER_ID).industry("IT").build())); when(replyRepository.findMonthlyReplyStats(any(), any(), any(), any())) .thenReturn(List.of(new Object[]{COMPANY_ID, 2L, 0L})); ReviewReplyStatsResponse r = replyService.getReviewReplyStats(COMPANY_USER_ID); assertEquals(0, r.getMonthReplyRate()); assertEquals(2, r.getUnrepliedCount()); } @Test void getReviewReplyStats_noReviewsThisMonth_shouldReturnZeros() { when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) .thenReturn(Optional.of(Company.builder().id(COMPANY_ID) .ownerUserId(COMPANY_USER_ID).industry("IT").build())); when(replyRepository.findMonthlyReplyStats(any(), any(), any(), any())).thenReturn(List.of()); ReviewReplyStatsResponse r = replyService.getReviewReplyStats(COMPANY_USER_ID); assertEquals(0, r.getMonthReplyRate()); assertEquals(0, r.getIndustryAvgReplyRate()); assertEquals(0, r.getUnrepliedCount()); } @Test void getReviewReplyStats_companyWithoutIndustry_shouldSetIndustryAvgZero() { when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) .thenReturn(Optional.of(Company.builder().id(COMPANY_ID) .ownerUserId(COMPANY_USER_ID).industry(null).build())); when(replyRepository.findMonthlyReplyStats(any(), any(), any(), any())) .thenReturn(List.of(new Object[]{COMPANY_ID, 5L, 4L})); ReviewReplyStatsResponse r = replyService.getReviewReplyStats(COMPANY_USER_ID); assertEquals(80, r.getMonthReplyRate()); assertEquals(0, r.getIndustryAvgReplyRate()); assertEquals(1, r.getUnrepliedCount()); } @Test void getReviewReplyStats_companyUserWithoutCompany_shouldReturn403() { when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.empty()); BusinessException ex = assertThrows(BusinessException.class, () -> replyService.getReviewReplyStats(COMPANY_USER_ID)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } private Long ADMIN_ROLE_ID() { return 1L; } }