From 10887f40ed4e5726a46dd3bfce9cb3fda4103077 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 10:01:50 +0800 Subject: [PATCH] feat: add V034-V035 flyway migrations and phase 04-07 test classes --- .../V034__extend_sensitive_access_audit.sql | 7 + ...35__align_sensitive_access_base_fields.sql | 4 + .../LocalEligibilityServiceTest.java | 91 +++++++++++++ .../RecommendationEligibilityPhase06Test.java | 126 ++++++++++++++++++ .../RelationshipBlockPriorityPhase08Test.java | 53 ++++++++ .../review/ReviewServicePhase05Test.java | 125 +++++++++++++++++ 6 files changed, 406 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V034__extend_sensitive_access_audit.sql create mode 100644 backend/src/main/resources/db/migration/V035__align_sensitive_access_base_fields.sql create mode 100644 backend/src/test/java/com/danyang/match/platform/local_eligibility/LocalEligibilityServiceTest.java create mode 100644 backend/src/test/java/com/danyang/match/platform/match/RecommendationEligibilityPhase06Test.java create mode 100644 backend/src/test/java/com/danyang/match/platform/relationship/RelationshipBlockPriorityPhase08Test.java create mode 100644 backend/src/test/java/com/danyang/match/platform/review/ReviewServicePhase05Test.java diff --git a/backend/src/main/resources/db/migration/V034__extend_sensitive_access_audit.sql b/backend/src/main/resources/db/migration/V034__extend_sensitive_access_audit.sql new file mode 100644 index 0000000..2965510 --- /dev/null +++ b/backend/src/main/resources/db/migration/V034__extend_sensitive_access_audit.sql @@ -0,0 +1,7 @@ +-- Phase 05: 敏感佐证访问审计记录具体目标和访问结果。 +ALTER TABLE sensitive_access_logs + ADD COLUMN IF NOT EXISTS access_target VARCHAR(128), + ADD COLUMN IF NOT EXISTS access_result VARCHAR(32); + +CREATE INDEX IF NOT EXISTS idx_sensitive_access_target + ON sensitive_access_logs(data_type, access_target, created_at DESC); diff --git a/backend/src/main/resources/db/migration/V035__align_sensitive_access_base_fields.sql b/backend/src/main/resources/db/migration/V035__align_sensitive_access_base_fields.sql new file mode 100644 index 0000000..009f370 --- /dev/null +++ b/backend/src/main/resources/db/migration/V035__align_sensitive_access_base_fields.sql @@ -0,0 +1,4 @@ +-- SensitiveAccessLog 继承 BaseEntity,补齐自动填充和软删除字段。 +ALTER TABLE sensitive_access_logs + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; diff --git a/backend/src/test/java/com/danyang/match/platform/local_eligibility/LocalEligibilityServiceTest.java b/backend/src/test/java/com/danyang/match/platform/local_eligibility/LocalEligibilityServiceTest.java new file mode 100644 index 0000000..6acf41e --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/local_eligibility/LocalEligibilityServiceTest.java @@ -0,0 +1,91 @@ +package com.danyang.match.platform.local_eligibility; + +import com.danyang.match.platform.local_eligibility.dto.LocalEligibilityEvidenceRequest; +import com.danyang.match.platform.local_eligibility.entity.LocalEligibility; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityEvidenceMapper; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; +import com.danyang.match.platform.local_eligibility.service.LocalEligibilityService; +import com.danyang.match.platform.review.service.ReviewService; +import com.danyang.match.platform.shared.config.FeatureFlagConfig; +import com.danyang.match.platform.user.mapper.UserMapper; +import com.danyang.match.platform.photo.mapper.UserPhotoMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LocalEligibilityServiceTest { + + @Mock private LocalEligibilityMapper eligibilityMapper; + @Mock private LocalEligibilityEvidenceMapper evidenceMapper; + @Mock private UserMapper userMapper; + @Mock private ReviewService reviewService; + @Mock private StringRedisTemplate redisTemplate; + @Mock private UserPhotoMapper userPhotoMapper; + private FeatureFlagConfig featureFlags; + private LocalEligibilityService service; + + @BeforeEach + void setUp() { + featureFlags = new FeatureFlagConfig(); + service = new LocalEligibilityService( + eligibilityMapper, evidenceMapper, userMapper, reviewService, + redisTemplate, featureFlags, userPhotoMapper); + } + + @Test + void rejectsEvidenceWhenConfiguredMaximumHasBeenReached() { + LocalEligibility eligibility = new LocalEligibility(); + eligibility.setId("eligibility-1"); + eligibility.setUserId("user-1"); + eligibility.setVerificationStatus("needs_evidence"); + when(eligibilityMapper.selectOne(any())).thenReturn(eligibility); + when(evidenceMapper.selectCount(any())).thenReturn(5L); + + LocalEligibilityEvidenceRequest request = new LocalEligibilityEvidenceRequest(); + request.setEvidenceType("employment_proof"); + request.setFileId("photos/user-1/local_eligibility/proof.jpg"); + + assertThrows(IllegalStateException.class, + () -> service.uploadEvidence("user-1", request)); + } + + @Test + void rejectsEvidenceFileThatDoesNotBelongToCurrentUser() { + LocalEligibility eligibility = eligibility("user-1"); + when(eligibilityMapper.selectOne(any())).thenReturn(eligibility); + when(evidenceMapper.selectCount(any())).thenReturn(0L); + when(userPhotoMapper.findByUserIdOrderBySort("user-1")).thenReturn(java.util.List.of()); + + assertThrows(SecurityException.class, + () -> service.uploadEvidence("user-1", request("employment_proof", "photos/other/proof.jpg"))); + } + + @Test + void rejectsUnsupportedEvidenceType() { + assertThrows(IllegalArgumentException.class, + () -> service.uploadEvidence("user-1", request("bank_statement", "photos/user-1/proof.jpg"))); + } + + private LocalEligibility eligibility(String userId) { + LocalEligibility eligibility = new LocalEligibility(); + eligibility.setId("eligibility-1"); + eligibility.setUserId(userId); + eligibility.setVerificationStatus("needs_evidence"); + return eligibility; + } + + private LocalEligibilityEvidenceRequest request(String type, String fileId) { + LocalEligibilityEvidenceRequest request = new LocalEligibilityEvidenceRequest(); + request.setEvidenceType(type); + request.setFileId(fileId); + return request; + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/match/RecommendationEligibilityPhase06Test.java b/backend/src/test/java/com/danyang/match/platform/match/RecommendationEligibilityPhase06Test.java new file mode 100644 index 0000000..b9b83f2 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/match/RecommendationEligibilityPhase06Test.java @@ -0,0 +1,126 @@ +package com.danyang.match.platform.match.service; + +import com.danyang.match.platform.local_eligibility.entity.LocalEligibility; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; +import com.danyang.match.platform.match.config.MatchRuleConfigProperties; +import com.danyang.match.platform.match.mapper.RecommendationRecordMapper; +import com.danyang.match.platform.profile.entity.UserPreference; +import com.danyang.match.platform.profile.entity.UserProfile; +import com.danyang.match.platform.profile.mapper.UserPreferenceMapper; +import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper; +import com.danyang.match.platform.profile.mapper.UserProfileMapper; +import com.danyang.match.platform.relationship.entity.UserRelation; +import com.danyang.match.platform.relationship.mapper.UserRelationMapper; +import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RecommendationEligibilityPhase06Test { + @Mock UserProfileMapper profileMapper; + @Mock UserPreferenceMapper preferenceMapper; + @Mock RecommendationRecordMapper recordMapper; + @Mock StringRedisTemplate redisTemplate; + @Mock UserRelationMapper relationMapper; + @Mock UserMapper userMapper; + @Mock UserPrivacySettingsMapper privacyMapper; + @Mock LocalEligibilityMapper eligibilityMapper; + MatchRuleConfigProperties config; + RecommendationEngineService service; + + @BeforeEach + void setUp() { + config = new MatchRuleConfigProperties(); + service = new RecommendationEngineService(profileMapper, preferenceMapper, recordMapper, + config, redisTemplate, relationMapper, userMapper, privacyMapper, eligibilityMapper); + } + + @Test + void pausedRequesterCannotReceiveRecommendation() { + when(userMapper.findById("user-a")).thenReturn(Optional.of(user("paused", true))); + assertFalse(service.isStillEligible("user-a", "user-b")); + verifyNoInteractions(profileMapper, relationMapper); + } + + @Test + void successfulTargetCannotReturnFromCacheOrRealtimeEligibility() { + stubEligibleParticipant("user-a"); + when(userMapper.findById("user-b")).thenReturn(Optional.of(user("matched_successfully", true))); + + assertFalse(service.isStillEligible("user-a", "user-b")); + verifyNoInteractions(profileMapper, relationMapper); + } + + @Test + void pendingLocalEligibilityIsRejectedWhenGateEnabled() { + config.setLocalEligibilityRequired(true); + when(userMapper.findById("user-a")).thenReturn(Optional.of(user("searching", true))); + when(privacyMapper.findByUserId("user-a")).thenReturn(Optional.empty()); + LocalEligibility pending = new LocalEligibility(); + pending.setVerificationStatus("pending_review"); + when(eligibilityMapper.findByUserId("user-a")).thenReturn(Optional.of(pending)); + + assertFalse(service.isStillEligible("user-a", "user-b")); + verify(userMapper, never()).findById("user-b"); + } + + @Test + void reverseBlockAlwaysRejectsOtherwiseEligiblePair() { + stubEligibleParticipant("user-a"); + stubEligibleParticipant("user-b"); + UserProfile target = profile("user-b", 1994); + when(profileMapper.findByUserId("user-b")).thenReturn(Optional.of(target)); + when(relationMapper.findByRelation("user-a", "user-b", Constants.RELATION_TYPE_BLOCKED)) + .thenReturn(Optional.empty()); + when(relationMapper.findByRelation("user-b", "user-a", Constants.RELATION_TYPE_BLOCKED)) + .thenReturn(Optional.of(new UserRelation())); + + assertFalse(service.isStillEligible("user-a", "user-b")); + } + + @Test + void eitherSideRequiredAgeFailureRejectsPair() { + UserPreference a = new UserPreference(); + a.setAgePriority("required"); + a.setMinAge(25); + a.setMaxAge(30); + UserPreference b = new UserPreference(); + UserProfile profileA = profile("user-a", 1995); + UserProfile profileB = profile("user-b", 1985); + + assertFalse(service.checkBidirectionalRequired(a, profileB, b, profileA)); + } + + private void stubEligibleParticipant(String id) { + when(userMapper.findById(id)).thenReturn(Optional.of(user("searching", true))); + when(privacyMapper.findByUserId(id)).thenReturn(Optional.empty()); + } + + private User user(String relationshipStatus, boolean recommendationEnabled) { + User user = new User(); + user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL); + user.setRelationshipStatus(relationshipStatus); + user.setRecommendationEnabled(recommendationEnabled); + return user; + } + + private UserProfile profile(String userId, int birthYear) { + UserProfile profile = new UserProfile(); + profile.setUserId(userId); + profile.setBirthDate(LocalDate.of(birthYear, 1, 1)); + profile.setProfileStatus(Constants.PROFILE_STATUS_APPROVED); + return profile; + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipBlockPriorityPhase08Test.java b/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipBlockPriorityPhase08Test.java new file mode 100644 index 0000000..7d324d7 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipBlockPriorityPhase08Test.java @@ -0,0 +1,53 @@ +package com.danyang.match.platform.relationship; + +import com.danyang.match.platform.audit.mapper.AuditLogMapper; +import com.danyang.match.platform.match.mapper.RecommendationRecordMapper; +import com.danyang.match.platform.messaging.service.MessagingService; +import com.danyang.match.platform.profile.mapper.UserProfileMapper; +import com.danyang.match.platform.relationship.entity.UserRelation; +import com.danyang.match.platform.relationship.mapper.MatchMapper; +import com.danyang.match.platform.relationship.mapper.UserRelationMapper; +import com.danyang.match.platform.relationship.service.RelationshipService; +import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.user.mapper.UserMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +class RelationshipBlockPriorityPhase08Test { + private UserRelationMapper relationMapper; + private RelationshipService service; + + @BeforeEach + void setUp() { + relationMapper = mock(UserRelationMapper.class); + service = new RelationshipService(relationMapper, mock(MatchMapper.class), + mock(UserProfileMapper.class), mock(RecommendationRecordMapper.class), + mock(AuditLogMapper.class), mock(MessagingService.class), mock(UserMapper.class)); + } + + @Test + void reverseDirectionBlockMustRejectLike() { + arrangeReverseBlock("user-b", "user-a"); + assertThrows(RuntimeException.class, () -> service.like("user-a", "user-b")); + verify(relationMapper, never()).insert(any(UserRelation.class)); + } + + @Test + void reverseDirectionBlockMustRejectPass() { + arrangeReverseBlock("user-b", "user-a"); + assertThrows(RuntimeException.class, () -> service.pass("user-a", "user-b")); + verify(relationMapper, never()).insert(any(UserRelation.class)); + } + + private void arrangeReverseBlock(String blocker, String blocked) { + when(relationMapper.findByRelation(blocked, blocker, Constants.RELATION_TYPE_BLOCKED)) + .thenReturn(Optional.empty()); + when(relationMapper.findByRelation(blocker, blocked, Constants.RELATION_TYPE_BLOCKED)) + .thenReturn(Optional.of(new UserRelation())); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/review/ReviewServicePhase05Test.java b/backend/src/test/java/com/danyang/match/platform/review/ReviewServicePhase05Test.java new file mode 100644 index 0000000..df85d6a --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/review/ReviewServicePhase05Test.java @@ -0,0 +1,125 @@ +package com.danyang.match.platform.review; + +import com.danyang.match.platform.audit.mapper.AuditLogMapper; +import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper; +import com.danyang.match.platform.local_eligibility.entity.LocalEligibility; +import com.danyang.match.platform.local_eligibility.entity.LocalEligibilityEvidence; +import com.danyang.match.platform.audit.entity.SensitiveAccessLog; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityEvidenceMapper; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; +import com.danyang.match.platform.notification.service.NotificationService; +import com.danyang.match.platform.photo.service.PhotoService; +import com.danyang.match.platform.profile.mapper.UserProfileMapper; +import com.danyang.match.platform.review.entity.ReviewTask; +import com.danyang.match.platform.review.mapper.ReviewItemMapper; +import com.danyang.match.platform.review.mapper.ReviewReasonMapper; +import com.danyang.match.platform.review.mapper.ReviewTaskMapper; +import com.danyang.match.platform.review.service.ReviewService; +import com.danyang.match.platform.shared.constant.Constants; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ReviewServicePhase05Test { + @Mock ReviewTaskMapper taskMapper; + @Mock ReviewItemMapper itemMapper; + @Mock ReviewReasonMapper reasonMapper; + @Mock UserProfileMapper profileMapper; + @Mock AuditLogMapper auditMapper; + @Mock LocalEligibilityMapper eligibilityMapper; + @Mock LocalEligibilityEvidenceMapper evidenceMapper; + @Mock SensitiveAccessLogMapper sensitiveAccessLogMapper; + @Mock NotificationService notificationService; + @Mock PhotoService photoService; + ReviewService service; + + @BeforeEach + void setUp() { + service = new ReviewService(taskMapper, itemMapper, reasonMapper, profileMapper, + auditMapper, eligibilityMapper, evidenceMapper, sensitiveAccessLogMapper, + notificationService, photoService); + } + + @Test + void onlyOneOperatorCanClaimTask() { + when(taskMapper.claimTaskAtomic(eq("task-1"), anyString(), any())).thenReturn(1, 0); + ReviewTask claimed = localTask("processing", "operator-a"); + when(taskMapper.selectById("task-1")).thenReturn(claimed); + + assertTrue(service.claimTask("operator-a", "task-1").isPresent()); + assertTrue(service.claimTask("operator-b", "task-1").isEmpty()); + } + + @Test + void localEligibilityDecisionRequiresClaimOwner() { + when(taskMapper.selectById("task-1")).thenReturn(localTask("processing", "operator-a")); + + RuntimeException error = assertThrows(RuntimeException.class, + () -> service.approveLocalEligibility("task-1", "operator-b", "ok")); + + assertTrue(error.getMessage().contains("领取")); + verify(taskMapper, never()).updateById(any()); + } + + @Test + void approvalUpdatesEligibilityAndReviewTaskTogether() { + ReviewTask task = localTask("processing", "operator-a"); + LocalEligibility eligibility = new LocalEligibility(); + eligibility.setId("eligibility-1"); + eligibility.setUserId("user-1"); + when(taskMapper.selectById("task-1")).thenReturn(task); + when(eligibilityMapper.selectOne(any())).thenReturn(eligibility); + + service.approveLocalEligibility("task-1", "operator-a", "材料有效"); + + assertEquals("approved", task.getStatus()); + assertEquals("verified", eligibility.getVerificationStatus()); + assertEquals("operator-a", eligibility.getVerifiedBy()); + verify(taskMapper).updateById(task); + verify(eligibilityMapper).updateById(eligibility); + } + + @Test + void evidenceAccessChecksOwnershipAndWritesSuccessfulAudit() { + ReviewTask task = localTask("processing", "operator-a"); + LocalEligibility eligibility = new LocalEligibility(); + eligibility.setId("eligibility-1"); + eligibility.setUserId("user-1"); + LocalEligibilityEvidence evidence = new LocalEligibilityEvidence(); + evidence.setId("evidence-1"); + evidence.setLocalEligibilityId("eligibility-1"); + evidence.setFileId("photos/user-1/local_eligibility/proof.jpg"); + when(taskMapper.selectById("task-1")).thenReturn(task); + when(eligibilityMapper.selectOne(any())).thenReturn(eligibility); + when(evidenceMapper.selectById("evidence-1")).thenReturn(evidence); + when(photoService.getSignedUrlForAuthorizedReview(evidence.getFileId())) + .thenReturn(java.util.Map.of("url", "signed-url", "expiresIn", 600)); + + var result = service.accessLocalEligibilityEvidence( + "task-1", "evidence-1", "operator-a", "核验居住证明"); + + assertEquals("signed-url", result.get("url")); + var captor = org.mockito.ArgumentCaptor.forClass(SensitiveAccessLog.class); + verify(sensitiveAccessLogMapper).insert(captor.capture()); + assertEquals("evidence-1", captor.getValue().getAccessTarget()); + assertEquals("success", captor.getValue().getAccessResult()); + assertEquals("operator-a", captor.getValue().getOperatorId()); + } + + private ReviewTask localTask(String status, String operatorId) { + ReviewTask task = new ReviewTask(); + task.setId("task-1"); + task.setUserId("user-1"); + task.setReviewType(Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY); + task.setStatus(status); + task.setAssignedOperatorId(operatorId); + return task; + } +}