package com.mydreamjob.match; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.security.UserRoleGuard; import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobService; import com.mydreamjob.preference.StudentPreference; import com.mydreamjob.preference.StudentPreferenceRepository; import com.mydreamjob.profile.StudentProfile; import com.mydreamjob.profile.StudentProfileRepository; 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 java.util.HashSet; import java.util.Map; import java.util.Optional; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class MatchServiceTest { @Mock private JobService jobService; @Mock private StudentPreferenceRepository preferenceRepository; @Mock private StudentProfileRepository profileRepository; @Mock private AuditService auditService; @Mock private UserRepository userRepository; /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ @Mock private UserRoleGuard userRoleGuard; @InjectMocks private MatchService matchService; private static final Long STUDENT_ID = 100L; private static final Long COMPANY_ID = 200L; private static final Long ADMIN_ID = 300L; private static final Long JOB_ID = 10L; private Job publishedJob; @BeforeEach void setUp() { // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); publishedJob = Job.builder().id(JOB_ID).companyId(1L).title("Java 后端") .city("杭州").employmentType("FULL_TIME") .salaryMax(20000).status("PUBLISHED").build(); } private void mockRole(Long userId, User.Role role) { User u = User.builder().id(userId).phone("1").role(role).build(); u.setRoles(new HashSet<>(Set.of(role))); when(userRepository.findById(userId)).thenReturn(Optional.of(u)); } private void mockPref() { StudentPreference p = StudentPreference.builder().studentUserId(STUDENT_ID) .preferredCities("[\"杭州\"]").employmentTypes("[\"FULL_TIME\"]") .expectedSalaryMin(10000).preferredIndustries("[\"互联网\"]") .preferredJobTypes("[\"Java\"]").remoteAllowed(false).build(); when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(p)); } private void mockProfile() { when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of( StudentProfile.builder().userId(STUDENT_ID).build())); } // ── 角色校验 ───────────────────────────────────────────── @Test void diagnose_asCompany_shouldReturn403() { mockRole(COMPANY_ID, User.Role.COMPANY); BusinessException ex = assertThrows(BusinessException.class, () -> matchService.diagnose(COMPANY_ID, JOB_ID)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } @Test void diagnose_asAdmin_shouldReturn403() { mockRole(ADMIN_ID, User.Role.ADMIN); BusinessException ex = assertThrows(BusinessException.class, () -> matchService.diagnose(ADMIN_ID, JOB_ID)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } // ── 完全匹配 ───────────────────────────────────────────── @Test void diagnose_fullMatch_shouldBeEligible() { mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); mockPref(); mockProfile(); Map r = matchService.diagnose(STUDENT_ID, JOB_ID); assertEquals(true, r.get("eligible")); assertEquals("RULES_V0_1", r.get("rulesVersion")); assertTrue((Integer) r.get("matchScore") >= 70); assertTrue(((java.util.List) r.get("matchedConditions")).size() >= 3); } // ── 部分匹配 ───────────────────────────────────────────── @Test void diagnose_partialMatch_shouldBeEligibleWithGaps() { mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); StudentPreference p = StudentPreference.builder().studentUserId(STUDENT_ID) .preferredCities("[\"上海\"]").employmentTypes("[\"PART_TIME\"]") .expectedSalaryMin(15000).build(); when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(p)); when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of( StudentProfile.builder().userId(STUDENT_ID).build())); Map r = matchService.diagnose(STUDENT_ID, JOB_ID); assertFalse(((java.util.List) r.get("gaps")).isEmpty()); } // ── 硬性条件不满足 ─────────────────────────────────────── @Test void diagnose_hardGap_salaryTooHigh_shouldBeIneligible() { mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); StudentPreference p = StudentPreference.builder().studentUserId(STUDENT_ID) .preferredCities("[\"杭州\"]").employmentTypes("[\"FULL_TIME\"]") .expectedSalaryMin(30000).build(); // 期望下限 > 岗位上限 20000 when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(p)); when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of( StudentProfile.builder().userId(STUDENT_ID).build())); Map r = matchService.diagnose(STUDENT_ID, JOB_ID); assertEquals(false, r.get("eligible")); assertTrue(((String) r.get("explanation")).contains("硬性条件")); } // ── 空档案 ─────────────────────────────────────────────── @Test void diagnose_emptyProfile_shouldBeIneligibleWithZero() { mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.empty()); Map r = matchService.diagnose(STUDENT_ID, JOB_ID); assertEquals(0, r.get("matchScore")); assertEquals(false, r.get("eligible")); assertTrue(((java.util.List) r.get("gaps")).contains("尚未完善档案与偏好")); } // ── 阈值边界 ───────────────────────────────────────────── @Test void diagnose_salaryBoundary_equal_shouldBeEligible() { mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); // salaryMax=20000 StudentPreference p = StudentPreference.builder().studentUserId(STUDENT_ID) .preferredCities("[\"杭州\"]").employmentTypes("[\"FULL_TIME\"]") .expectedSalaryMin(20000).build(); // 等于上限,边界 when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(p)); when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of( StudentProfile.builder().userId(STUDENT_ID).build())); Map r = matchService.diagnose(STUDENT_ID, JOB_ID); assertEquals(true, r.get("eligible")); } // ── 岗位不存在 ─────────────────────────────────────────── @Test void diagnose_jobNotFound_shouldReturn404() { mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(999L)).thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND)); assertThrows(BusinessException.class, () -> matchService.diagnose(STUDENT_ID, 999L)); } // ── 不泄露敏感数据 ─────────────────────────────────────── @Test void diagnose_shouldNotIncludeSensitiveData() { mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); mockPref(); mockProfile(); Map r = matchService.diagnose(STUDENT_ID, JOB_ID); String json = r.toString(); assertFalse(json.contains("phone")); assertFalse(json.contains("resume")); } // ── 审计记录 ───────────────────────────────────────────── @Test void diagnose_shouldLogAudit() { mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); mockPref(); mockProfile(); matchService.diagnose(STUDENT_ID, JOB_ID); verify(auditService, times(1)).log(eq(STUDENT_ID), eq("MATCH_DIAGNOSE"), eq("JOB"), eq(JOB_ID), anyString(), isNull()); } @Test void diagnose_withContext_shouldPersistContext() { mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); mockPref(); mockProfile(); com.mydreamjob.audit.AuditContext ctx = com.mydreamjob.audit.AuditContext.builder() .requestId("req-ctx-1").ipAddress("10.1.1.1").userAgent("TestAgent").build(); matchService.diagnose(STUDENT_ID, JOB_ID, ctx); verify(auditService, times(1)).log(eq(STUDENT_ID), eq("MATCH_DIAGNOSE"), eq("JOB"), eq(JOB_ID), anyString(), eq(ctx)); } }