From 948f022434098b901cc27bf7f3ba61ae910ca9ea Mon Sep 17 00:00:00 2001 From: sgo722 Date: Tue, 13 Jan 2026 16:41:20 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[fix]=20Code=20Time=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EB=82=B4=20=EC=8B=9C=EA=B7=B8=EB=84=90=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 추천 세션 일관성 유지를 위해 실시간 필터링에서 시그널 체크 제거 - 차단 관계만 즉시 제외, 시그널은 다음 세션부터 제외 - DailyCodeMatchingService와 동작 통일 Resolves #386 --- .../recommendation/business/CodeTimeService.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/codel/recommendation/business/CodeTimeService.kt b/src/main/kotlin/codel/recommendation/business/CodeTimeService.kt index 8f498a4..1eae8a8 100644 --- a/src/main/kotlin/codel/recommendation/business/CodeTimeService.kt +++ b/src/main/kotlin/codel/recommendation/business/CodeTimeService.kt @@ -144,9 +144,11 @@ class CodeTimeService( * 제외 대상: * - 차단한 사용자 * - 나를 차단한 사용자 - * - 최근 시그널 보낸 사용자 * - WITHDRAWN 상태의 사용자 (회원 탈퇴) * + * 주의: 시그널 관계는 실시간 필터링에서 제외하지 않음 + * → 추천 세션 일관성 유지를 위해 새로운 추천 생성 시에만 제외 + * * @param user 기준 사용자 * @param memberIds 필터링할 사용자 ID 목록 * @return 필터링된 사용자 ID 목록 @@ -156,15 +158,21 @@ class CodeTimeService( return emptyList() } + // 실시간 제외 대상 조회 (차단만) val excludeIds = mutableSetOf() + + // 1. 차단 관계만 확인 (즉시 반영) excludeIds.addAll(exclusionService.getBlockedMemberIds(user)) - excludeIds.addAll(exclusionService.getRecentSignalMemberIds(user)) - // WITHDRAWN 상태의 회원 필터링 + // 2. 시그널 관계는 확인하지 않음 (추천 세션 일관성 유지) + // → 새로운 추천 생성 시에만 제외됨 + + // 3. WITHDRAWN 상태의 회원 필터링 // getMembersByIds를 통해 조회하면 자동으로 WITHDRAWN이 제외됨 val validMembers = bucketService.getMembersByIds(memberIds) val validIds = validMembers.map { it.getIdOrThrow() } + // 4. 최종 필터링 val filteredIds = validIds.filter { it !in excludeIds } log.debug { From bbd98413834bbf279f6ddd6ed0f1b0c922582f04 Mon Sep 17 00:00:00 2001 From: sgo722 Date: Tue, 13 Jan 2026 16:41:26 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[fix]=20Code=20Time=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EC=A0=95=EB=A0=AC=20=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DESC를 ASC로 변경하여 생성 순서 유지 - 버킷 정책 우선순위(B1→B2→B3→B4) 보존 - Daily Code Matching과 정렬 방향 통일 Resolves #386 --- .../RecommendationHistoryJpaRepository.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/codel/recommendation/infrastructure/RecommendationHistoryJpaRepository.kt b/src/main/kotlin/codel/recommendation/infrastructure/RecommendationHistoryJpaRepository.kt index 5dcbef8..6f15442 100644 --- a/src/main/kotlin/codel/recommendation/infrastructure/RecommendationHistoryJpaRepository.kt +++ b/src/main/kotlin/codel/recommendation/infrastructure/RecommendationHistoryJpaRepository.kt @@ -112,22 +112,22 @@ interface RecommendationHistoryJpaRepository : JpaRepository= :startDateTime AND rh.recommendedAt < :endDateTime - ORDER BY rh.createdAt DESC + ORDER BY rh.createdAt ASC """) fun findCodeTimeIdsByTimeRange( @Param("user") user: Member, From 9a0eb249c43d11705c580bde54893b211c24d756 Mon Sep 17 00:00:00 2001 From: sgo722 Date: Wed, 14 Jan 2026 17:40:37 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[test]=20CodeTimeService=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 추천 세션 일관성 검증 (시그널 제외 로직) - 차단 관계 즉시 제외 검증 - 추천 순서 유지 검증 - WITHDRAWN 회원 자동 필터링 검증 - mockito-kotlin 의존성 추가 - PreVerificationStrategyTest asyncNotificationService 파라미터 추가 Resolves #386 Co-Authored-By: Claude Sonnet 4.5 --- build.gradle.kts | 1 + .../signup/PreVerificationStrategyTest.kt | 34 +- .../business/CodeTimeServiceTest.kt | 345 ++++++++++++++++++ 3 files changed, 371 insertions(+), 9 deletions(-) create mode 100644 src/test/kotlin/codel/recommendation/business/CodeTimeServiceTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 196caaf..3a376a8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { testImplementation("io.rest-assured:rest-assured:5.3.1") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0") testRuntimeOnly("org.junit.platform:junit-platform-launcher") // jwt diff --git a/src/test/kotlin/codel/member/business/signup/PreVerificationStrategyTest.kt b/src/test/kotlin/codel/member/business/signup/PreVerificationStrategyTest.kt index e607fbc..aedfb20 100644 --- a/src/test/kotlin/codel/member/business/signup/PreVerificationStrategyTest.kt +++ b/src/test/kotlin/codel/member/business/signup/PreVerificationStrategyTest.kt @@ -4,7 +4,9 @@ import codel.member.business.SignupService import codel.member.domain.Member import codel.member.domain.MemberStatus import codel.member.domain.OauthType +import codel.member.domain.Profile import codel.member.infrastructure.MemberJpaRepository +import codel.notification.business.IAsyncNotificationService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -13,49 +15,63 @@ import org.mockito.ArgumentCaptor import org.mockito.Mockito.* import org.springframework.http.HttpStatus import org.springframework.mock.web.MockMultipartFile +import java.time.LocalDate class PreVerificationStrategyTest { private lateinit var signupService: SignupService private lateinit var memberJpaRepository: MemberJpaRepository + private lateinit var asyncNotificationService: IAsyncNotificationService private lateinit var strategy: PreVerificationStrategy @BeforeEach fun setUp() { signupService = mock(SignupService::class.java) memberJpaRepository = mock(MemberJpaRepository::class.java) - strategy = PreVerificationStrategy(signupService, memberJpaRepository) + asyncNotificationService = mock(IAsyncNotificationService::class.java) + strategy = PreVerificationStrategy(signupService, memberJpaRepository, asyncNotificationService) } - @DisplayName("PERSONALITY_COMPLETED 상태에서는 히든 이미지 등록 후 HIDDEN_COMPLETED 상태로 변경한다") + @DisplayName("PERSONALITY_COMPLETED 상태에서는 히든 이미지 등록 후 PENDING 상태로 변경한다") @Test - fun handleHiddenImages_personalityCompleted_changeToHiddenCompleted() { + fun handleHiddenImages_personalityCompleted_changeToPending() { // given + val profile = Profile( + id = 1L, + codeName = "테스트유저", + bigCity = "서울", + smallCity = "강남구", + birthDate = LocalDate.of(1990, 1, 1) + ) + val member = Member( id = 1L, oauthId = "test-oauth-id", oauthType = OauthType.KAKAO, memberStatus = MemberStatus.PERSONALITY_COMPLETED, - email = "test@test.com" + email = "test@test.com", + profile = profile ) + profile.member = member + val images = listOf( MockMultipartFile("image1", "test1.jpg", "image/jpeg", "test1".toByteArray()), MockMultipartFile("image2", "test2.jpg", "image/jpeg", "test2".toByteArray()), MockMultipartFile("image3", "test3.jpg", "image/jpeg", "test3".toByteArray()) ) + // memberJpaRepository.findByMemberId가 member를 반환하도록 mock 설정 + `when`(memberJpaRepository.findByMemberId(1L)).thenReturn(member) + // when val response = strategy.handleHiddenImages(member, images) // then verify(signupService, times(1)).registerHiddenImages(member, images) + verify(memberJpaRepository, times(1)).findByMemberId(1L) - val memberCaptor = ArgumentCaptor.forClass(Member::class.java) - verify(memberJpaRepository, times(1)).save(memberCaptor.capture()) - - val savedMember = memberCaptor.value - assertEquals(MemberStatus.HIDDEN_COMPLETED, savedMember.memberStatus) + assertEquals(MemberStatus.PENDING, member.memberStatus) assertEquals(HttpStatus.OK, response.statusCode) } } diff --git a/src/test/kotlin/codel/recommendation/business/CodeTimeServiceTest.kt b/src/test/kotlin/codel/recommendation/business/CodeTimeServiceTest.kt new file mode 100644 index 0000000..643ac52 --- /dev/null +++ b/src/test/kotlin/codel/recommendation/business/CodeTimeServiceTest.kt @@ -0,0 +1,345 @@ +package codel.recommendation.business + +import codel.member.domain.Member +import codel.member.domain.MemberStatus +import codel.member.domain.OauthType +import codel.member.domain.Profile +import codel.recommendation.domain.RecommendationConfig +import codel.recommendation.domain.RecommendationType +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.* +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * CodeTimeService 테스트 + * + * 주요 검증 사항: + * 1. 추천 세션 일관성 - 시그널 보내도 같은 세션 내에서 계속 표시 + * 2. 차단 관계만 즉시 제외 + * 3. 추천 순서 유지 + */ +@DisplayName("CodeTimeService 테스트") +class CodeTimeServiceTest { + + private lateinit var codeTimeService: CodeTimeService + private lateinit var config: RecommendationConfig + private lateinit var bucketService: RecommendationBucketService + private lateinit var historyService: RecommendationHistoryService + private lateinit var exclusionService: RecommendationExclusionService + private lateinit var timeZoneService: TimeZoneService + + @BeforeEach + fun setUp() { + config = mock() + bucketService = mock() + historyService = mock() + exclusionService = mock() + timeZoneService = mock() + + codeTimeService = CodeTimeService( + config = config, + bucketService = bucketService, + historyService = historyService, + exclusionService = exclusionService, + timeZoneService = timeZoneService + ) + + // 기본 설정 + whenever(config.codeTimeCount).thenReturn(2) + whenever(config.codeTimeSlots).thenReturn(listOf("10:00", "22:00")) + } + + @Test + @DisplayName("기존 추천이 없으면 새로운 추천을 생성한다") + fun createNewRecommendation_WhenNoHistory() { + // given + val user = createTestMember(1L, "사용자A") + val recommendedMembers = listOf( + createTestMember(2L, "추천B"), + createTestMember(3L, "추천C") + ) + + whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") + whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( + Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) + ) + whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(emptyList()) + + // 새로운 추천 생성 관련 Mock + whenever(exclusionService.getAllExcludedIds(user, RecommendationType.CODE_TIME)).thenReturn(setOf(1L)) + whenever(bucketService.getCandidatesByBucket(any(), any(), any(), any())).thenReturn(recommendedMembers) + doNothing().whenever(historyService).saveRecommendationHistory(any(), any(), any(), any(), any()) + + // when + val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) + + // then + assertEquals(2, result.content.size) + assertEquals(recommendedMembers, result.content) + + // 추천 이력 저장 확인 + verify(historyService, times(1)) + .saveRecommendationHistory( + eq(user), + eq(recommendedMembers), + eq(RecommendationType.CODE_TIME), + eq("10:00"), + any() + ) + } + + @Test + @DisplayName("기존 추천이 있으면 실시간 필터링 후 반환한다") + fun returnExistingRecommendation_WithRealTimeFiltering() { + // given + val user = createTestMember(1L, "사용자A") + val existingIds = listOf(2L, 3L, 4L) + val existingMembers = listOf( + createTestMember(2L, "추천B"), + createTestMember(3L, "추천C"), + createTestMember(4L, "추천D") + ) + + whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") + whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( + Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) + ) + whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) + + // 실시간 필터링 - 차단 없음 + whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet()) + whenever(bucketService.getMembersByIds(existingIds)).thenReturn(existingMembers) + + // when + val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) + + // then + assertEquals(3, result.content.size) + assertEquals(existingMembers, result.content) + + // 새로운 추천 생성하지 않았는지 확인 + verify(historyService, never()) + .saveRecommendationHistory(any(), any(), any(), any(), any()) + } + + @Test + @DisplayName("차단한 사용자는 실시간 필터링에서 즉시 제외된다") + fun filterBlockedMembers_InRealTime() { + // given + val user = createTestMember(1L, "사용자A") + val existingIds = listOf(2L, 3L, 4L) + val memberB = createTestMember(2L, "추천B") + val memberC = createTestMember(3L, "추천C") + val memberD = createTestMember(4L, "추천D") + val allMembers = listOf(memberB, memberC, memberD) + + whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") + whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( + Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) + ) + whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) + + // 실시간 필터링 - B를 차단함 + whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(setOf(2L)) + // getMembersByIds는 요청된 ID에 해당하는 멤버만 반환 + whenever(bucketService.getMembersByIds(any())).thenAnswer { invocation -> + val requestedIds = invocation.getArgument>(0) + allMembers.filter { it.id in requestedIds } + } + + // when + val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) + + // then + assertEquals(2, result.content.size) + assertTrue(result.content.none { it.id == 2L }) // B는 제외됨 + assertTrue(result.content.any { it.id == 3L }) // C는 포함됨 + assertTrue(result.content.any { it.id == 4L }) // D는 포함됨 + } + + @Test + @DisplayName("시그널 보낸 사용자는 실시간 필터링에서 제외되지 않는다 - 추천 세션 일관성 유지") + fun doNotFilterSignaledMembers_InRealTime() { + // given + val user = createTestMember(1L, "사용자A") + val existingIds = listOf(2L, 3L, 4L) + val existingMembers = listOf( + createTestMember(2L, "추천B-시그널보냄"), + createTestMember(3L, "추천C"), + createTestMember(4L, "추천D") + ) + + whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") + whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( + Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) + ) + whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) + + // 실시간 필터링 - 차단 없음 (시그널 관계는 체크하지 않음) + whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet()) + // ⚠️ getRecentSignalMemberIds는 호출되지 않아야 함 + whenever(bucketService.getMembersByIds(existingIds)).thenReturn(existingMembers) + + // when + val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) + + // then + assertEquals(3, result.content.size) + assertTrue(result.content.any { it.id == 2L }) // B는 시그널 보냈지만 여전히 표시됨 ✅ + + // getRecentSignalMemberIds가 호출되지 않았는지 확인 + verify(exclusionService, never()).getRecentSignalMemberIds(any()) + } + + @Test + @DisplayName("WITHDRAWN 상태의 회원은 자동으로 필터링된다") + fun filterWithdrawnMembers_Automatically() { + // given + val user = createTestMember(1L, "사용자A") + val existingIds = listOf(2L, 3L, 4L) + val memberC = createTestMember(3L, "추천C", MemberStatus.DONE) + val memberD = createTestMember(4L, "추천D", MemberStatus.DONE) + // memberB(2L)는 WITHDRAWN이므로 getMembersByIds에서 자동으로 필터링됨 + val activeMembers = listOf(memberC, memberD) + + whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") + whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( + Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) + ) + whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) + + whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet()) + // getMembersByIds는 WITHDRAWN을 자동으로 필터링하고 요청된 ID에 해당하는 멤버만 반환 + whenever(bucketService.getMembersByIds(any())).thenAnswer { invocation -> + val requestedIds = invocation.getArgument>(0) + activeMembers.filter { it.id in requestedIds } + } + + // when + val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) + + // then + assertEquals(2, result.content.size) + assertTrue(result.content.none { it.id == 2L }) // B(탈퇴)는 제외됨 + assertTrue(result.content.any { it.id == 3L }) + assertTrue(result.content.any { it.id == 4L }) + } + + @Test + @DisplayName("추천 순서가 유지된다 - getMembersByIds의 순서 보존") + fun maintainRecommendationOrder() { + // given + val user = createTestMember(1L, "사용자A") + // 순서: B1 버킷, B1 버킷, B2 버킷 + val existingIds = listOf(2L, 3L, 4L) + val orderedMembers = listOf( + createTestMember(2L, "B1-강남"), + createTestMember(3L, "B1-강남2"), + createTestMember(4L, "B2-홍대") + ) + + whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") + whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( + Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) + ) + whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) + + whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet()) + // getMembersByIds는 입력 순서를 보존함 + whenever(bucketService.getMembersByIds(existingIds)).thenReturn(orderedMembers) + + // when + val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) + + // then + assertEquals(3, result.content.size) + assertEquals(2L, result.content[0].id) // 첫 번째: B1-강남 + assertEquals(3L, result.content[1].id) // 두 번째: B1-강남2 + assertEquals(4L, result.content[2].id) // 세 번째: B2-홍대 + } + + @Test + @DisplayName("모든 추천이 필터링되면 빈 페이지를 반환한다") + fun returnEmptyPage_WhenAllFiltered() { + // given + val user = createTestMember(1L, "사용자A") + val existingIds = listOf(2L, 3L) + + whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") + whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( + Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) + ) + whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) + + // 모두 차단 + whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(setOf(2L, 3L)) + whenever(bucketService.getMembersByIds(existingIds)).thenReturn(emptyList()) + + // when + val result = codeTimeService.getCodeTimeRecommendation(user, 0, 10) + + // then + assertEquals(0, result.content.size) + assertEquals(0, result.totalElements) + } + + @Test + @DisplayName("페이징이 올바르게 적용된다") + fun applyPaginationCorrectly() { + // given + val user = createTestMember(1L, "사용자A") + val existingIds = listOf(2L, 3L, 4L, 5L, 6L) + val existingMembers = (2L..6L).map { createTestMember(it, "추천$it") } + + whenever(timeZoneService.getCurrentTimeSlot(null)).thenReturn("10:00") + whenever(timeZoneService.getTimeSlotRangeInUTC("10:00", null)).thenReturn( + Pair(LocalDateTime.now(), LocalDateTime.now().plusHours(12)) + ) + whenever(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds) + + whenever(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet()) + whenever(bucketService.getMembersByIds(existingIds)).thenReturn(existingMembers) + + // when + val page = 0 + val size = 3 + val result = codeTimeService.getCodeTimeRecommendation(user, page, size) + + // then + assertEquals(5, result.content.size) // 페이징은 PageImpl에서 처리되므로 전체 반환 + assertEquals(5, result.totalElements) + } + + // Helper methods + + private fun createTestMember( + id: Long, + name: String, + status: MemberStatus = MemberStatus.DONE + ): Member { + val profile = Profile( + id = id, + codeName = name, + bigCity = "서울", + smallCity = "강남구", + birthDate = LocalDate.of(1990, 1, 1) + ) + + val member = Member( + id = id, + oauthId = "oauth-$id", + oauthType = OauthType.KAKAO, + memberStatus = status, + email = "$name@test.com", + profile = profile + ) + + profile.member = member + + return member + } +}