1+ package codel.recommendation.business
2+
3+ import codel.member.domain.Member
4+ import codel.member.domain.MemberStatus
5+ import codel.member.domain.OauthType
6+ import codel.member.domain.Profile
7+ import codel.recommendation.domain.RecommendationConfig
8+ import codel.recommendation.domain.RecommendationType
9+ import org.junit.jupiter.api.Assertions.*
10+ import org.junit.jupiter.api.BeforeEach
11+ import org.junit.jupiter.api.DisplayName
12+ import org.junit.jupiter.api.Test
13+ import org.mockito.ArgumentMatchers.any
14+ import org.mockito.ArgumentMatchers.eq
15+ import org.mockito.Mockito.*
16+ import java.time.LocalDate
17+ import java.time.LocalDateTime
18+
19+ /* *
20+ * CodeTimeService 테스트
21+ *
22+ * 주요 검증 사항:
23+ * 1. 추천 세션 일관성 - 시그널 보내도 같은 세션 내에서 계속 표시
24+ * 2. 차단 관계만 즉시 제외
25+ * 3. 추천 순서 유지
26+ */
27+ @DisplayName(" CodeTimeService 테스트" )
28+ class CodeTimeServiceTest {
29+
30+ private lateinit var codeTimeService: CodeTimeService
31+ private lateinit var config: RecommendationConfig
32+ private lateinit var bucketService: RecommendationBucketService
33+ private lateinit var historyService: RecommendationHistoryService
34+ private lateinit var exclusionService: RecommendationExclusionService
35+ private lateinit var timeZoneService: TimeZoneService
36+ private lateinit var agePreferenceResolver: AgePreferenceResolver
37+
38+ @BeforeEach
39+ fun setUp () {
40+ config = mock(RecommendationConfig ::class .java)
41+ bucketService = mock(RecommendationBucketService ::class .java)
42+ historyService = mock(RecommendationHistoryService ::class .java)
43+ exclusionService = mock(RecommendationExclusionService ::class .java)
44+ timeZoneService = mock(TimeZoneService ::class .java)
45+ agePreferenceResolver = mock(AgePreferenceResolver ::class .java)
46+
47+ codeTimeService = CodeTimeService (
48+ config = config,
49+ bucketService = bucketService,
50+ historyService = historyService,
51+ exclusionService = exclusionService,
52+ timeZoneService = timeZoneService,
53+ agePreferenceResolver = agePreferenceResolver
54+ )
55+
56+ // 기본 설정
57+ `when `(config.codeTimeCount).thenReturn(2 )
58+ `when `(config.codeTimeSlots).thenReturn(listOf (" 10:00" , " 22:00" ))
59+ }
60+
61+ @Test
62+ @DisplayName(" 기존 추천이 없으면 새로운 추천을 생성한다" )
63+ fun createNewRecommendation_WhenNoHistory () {
64+ // given
65+ val user = createTestMember(1L , " 사용자A" )
66+ val recommendedMembers = listOf (
67+ createTestMember(2L , " 추천B" ),
68+ createTestMember(3L , " 추천C" )
69+ )
70+
71+ `when `(timeZoneService.getCurrentTimeSlot(null )).thenReturn(" 10:00" )
72+ `when `(timeZoneService.getTimeSlotRangeInUTC(" 10:00" , null )).thenReturn(
73+ Pair (LocalDateTime .now(), LocalDateTime .now().plusHours(12 ))
74+ )
75+ `when `(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(emptyList())
76+
77+ // 새로운 추천 생성 관련 Mock
78+ `when `(exclusionService.getAllExcludedIds(user, RecommendationType .CODE_TIME )).thenReturn(setOf (1L ))
79+ `when `(bucketService.getCandidatesByBucket(any(), any(), any(), any())).thenReturn(recommendedMembers)
80+ doNothing().`when `(historyService).saveRecommendationHistory(any(), any(), any(), any(), any())
81+
82+ // when
83+ val result = codeTimeService.getCodeTimeRecommendation(user, 0 , 10 )
84+
85+ // then
86+ assertEquals(2 , result.content.size)
87+ assertEquals(recommendedMembers, result.content)
88+
89+ // 추천 이력 저장 확인
90+ verify(historyService, times(1 ))
91+ .saveRecommendationHistory(
92+ eq(user),
93+ eq(recommendedMembers),
94+ eq(RecommendationType .CODE_TIME ),
95+ eq(" 10:00" ),
96+ any()
97+ )
98+ }
99+
100+ @Test
101+ @DisplayName(" 기존 추천이 있으면 실시간 필터링 후 반환한다" )
102+ fun returnExistingRecommendation_WithRealTimeFiltering () {
103+ // given
104+ val user = createTestMember(1L , " 사용자A" )
105+ val existingIds = listOf (2L , 3L , 4L )
106+ val existingMembers = listOf (
107+ createTestMember(2L , " 추천B" ),
108+ createTestMember(3L , " 추천C" ),
109+ createTestMember(4L , " 추천D" )
110+ )
111+
112+ `when `(timeZoneService.getCurrentTimeSlot(null )).thenReturn(" 10:00" )
113+ `when `(timeZoneService.getTimeSlotRangeInUTC(" 10:00" , null )).thenReturn(
114+ Pair (LocalDateTime .now(), LocalDateTime .now().plusHours(12 ))
115+ )
116+ `when `(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds)
117+
118+ // 실시간 필터링 - 차단 없음
119+ `when `(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet())
120+ `when `(bucketService.getMembersByIds(existingIds)).thenReturn(existingMembers)
121+
122+ // when
123+ val result = codeTimeService.getCodeTimeRecommendation(user, 0 , 10 )
124+
125+ // then
126+ assertEquals(3 , result.content.size)
127+ assertEquals(existingMembers, result.content)
128+
129+ // 새로운 추천 생성하지 않았는지 확인
130+ verify(historyService, never())
131+ .saveRecommendationHistory(any(), any(), any(), any(), any())
132+ }
133+
134+ @Test
135+ @DisplayName(" 차단한 사용자는 실시간 필터링에서 즉시 제외된다" )
136+ fun filterBlockedMembers_InRealTime () {
137+ // given
138+ val user = createTestMember(1L , " 사용자A" )
139+ val existingIds = listOf (2L , 3L , 4L )
140+ val memberB = createTestMember(2L , " 추천B" )
141+ val memberC = createTestMember(3L , " 추천C" )
142+ val memberD = createTestMember(4L , " 추천D" )
143+ val allMembers = listOf (memberB, memberC, memberD)
144+
145+ `when `(timeZoneService.getCurrentTimeSlot(null )).thenReturn(" 10:00" )
146+ `when `(timeZoneService.getTimeSlotRangeInUTC(" 10:00" , null )).thenReturn(
147+ Pair (LocalDateTime .now(), LocalDateTime .now().plusHours(12 ))
148+ )
149+ `when `(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds)
150+
151+ // 실시간 필터링 - B를 차단함
152+ `when `(exclusionService.getBlockedMemberIds(user)).thenReturn(setOf (2L ))
153+ // getMembersByIds는 요청된 ID에 해당하는 멤버만 반환
154+ `when `(bucketService.getMembersByIds(any())).thenAnswer { invocation ->
155+ @Suppress(" UNCHECKED_CAST" )
156+ val requestedIds = invocation.getArgument<List <Long >>(0 )
157+ allMembers.filter { it.id in requestedIds }
158+ }
159+
160+ // when
161+ val result = codeTimeService.getCodeTimeRecommendation(user, 0 , 10 )
162+
163+ // then
164+ assertEquals(2 , result.content.size)
165+ assertTrue(result.content.none { it.id == 2L }) // B는 제외됨
166+ assertTrue(result.content.any { it.id == 3L }) // C는 포함됨
167+ assertTrue(result.content.any { it.id == 4L }) // D는 포함됨
168+ }
169+
170+ @Test
171+ @DisplayName(" 시그널 보낸 사용자는 실시간 필터링에서 제외되지 않는다 - 추천 세션 일관성 유지" )
172+ fun doNotFilterSignaledMembers_InRealTime () {
173+ // given
174+ val user = createTestMember(1L , " 사용자A" )
175+ val existingIds = listOf (2L , 3L , 4L )
176+ val existingMembers = listOf (
177+ createTestMember(2L , " 추천B-시그널보냄" ),
178+ createTestMember(3L , " 추천C" ),
179+ createTestMember(4L , " 추천D" )
180+ )
181+
182+ `when `(timeZoneService.getCurrentTimeSlot(null )).thenReturn(" 10:00" )
183+ `when `(timeZoneService.getTimeSlotRangeInUTC(" 10:00" , null )).thenReturn(
184+ Pair (LocalDateTime .now(), LocalDateTime .now().plusHours(12 ))
185+ )
186+ `when `(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds)
187+
188+ // 실시간 필터링 - 차단 없음 (시그널 관계는 체크하지 않음)
189+ `when `(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet())
190+ // getRecentSignalMemberIds는 호출되지 않아야 함
191+ `when `(bucketService.getMembersByIds(existingIds)).thenReturn(existingMembers)
192+
193+ // when
194+ val result = codeTimeService.getCodeTimeRecommendation(user, 0 , 10 )
195+
196+ // then
197+ assertEquals(3 , result.content.size)
198+ assertTrue(result.content.any { it.id == 2L }) // B는 시그널 보냈지만 여전히 표시됨
199+
200+ // getRecentSignalMemberIds가 호출되지 않았는지 확인
201+ verify(exclusionService, never()).getRecentSignalMemberIds(any())
202+ }
203+
204+ @Test
205+ @DisplayName(" WITHDRAWN 상태의 회원은 자동으로 필터링된다" )
206+ fun filterWithdrawnMembers_Automatically () {
207+ // given
208+ val user = createTestMember(1L , " 사용자A" )
209+ val existingIds = listOf (2L , 3L , 4L )
210+ val memberC = createTestMember(3L , " 추천C" , MemberStatus .DONE )
211+ val memberD = createTestMember(4L , " 추천D" , MemberStatus .DONE )
212+ // memberB(2L)는 WITHDRAWN이므로 getMembersByIds에서 자동으로 필터링됨
213+ val activeMembers = listOf (memberC, memberD)
214+
215+ `when `(timeZoneService.getCurrentTimeSlot(null )).thenReturn(" 10:00" )
216+ `when `(timeZoneService.getTimeSlotRangeInUTC(" 10:00" , null )).thenReturn(
217+ Pair (LocalDateTime .now(), LocalDateTime .now().plusHours(12 ))
218+ )
219+ `when `(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds)
220+
221+ `when `(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet())
222+ // getMembersByIds는 WITHDRAWN을 자동으로 필터링하고 요청된 ID에 해당하는 멤버만 반환
223+ `when `(bucketService.getMembersByIds(any())).thenAnswer { invocation ->
224+ @Suppress(" UNCHECKED_CAST" )
225+ val requestedIds = invocation.getArgument<List <Long >>(0 )
226+ activeMembers.filter { it.id in requestedIds }
227+ }
228+
229+ // when
230+ val result = codeTimeService.getCodeTimeRecommendation(user, 0 , 10 )
231+
232+ // then
233+ assertEquals(2 , result.content.size)
234+ assertTrue(result.content.none { it.id == 2L }) // B(탈퇴)는 제외됨
235+ assertTrue(result.content.any { it.id == 3L })
236+ assertTrue(result.content.any { it.id == 4L })
237+ }
238+
239+ @Test
240+ @DisplayName(" 추천 순서가 유지된다 - getMembersByIds의 순서 보존" )
241+ fun maintainRecommendationOrder () {
242+ // given
243+ val user = createTestMember(1L , " 사용자A" )
244+ // 순서: B1 버킷, B1 버킷, B2 버킷
245+ val existingIds = listOf (2L , 3L , 4L )
246+ val orderedMembers = listOf (
247+ createTestMember(2L , " B1-강남" ),
248+ createTestMember(3L , " B1-강남2" ),
249+ createTestMember(4L , " B2-홍대" )
250+ )
251+
252+ `when `(timeZoneService.getCurrentTimeSlot(null )).thenReturn(" 10:00" )
253+ `when `(timeZoneService.getTimeSlotRangeInUTC(" 10:00" , null )).thenReturn(
254+ Pair (LocalDateTime .now(), LocalDateTime .now().plusHours(12 ))
255+ )
256+ `when `(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds)
257+
258+ `when `(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet())
259+ // getMembersByIds는 입력 순서를 보존함
260+ `when `(bucketService.getMembersByIds(existingIds)).thenReturn(orderedMembers)
261+
262+ // when
263+ val result = codeTimeService.getCodeTimeRecommendation(user, 0 , 10 )
264+
265+ // then
266+ assertEquals(3 , result.content.size)
267+ assertEquals(2L , result.content[0 ].id) // 첫 번째: B1-강남
268+ assertEquals(3L , result.content[1 ].id) // 두 번째: B1-강남2
269+ assertEquals(4L , result.content[2 ].id) // 세 번째: B2-홍대
270+ }
271+
272+ @Test
273+ @DisplayName(" 모든 추천이 필터링되면 빈 페이지를 반환한다" )
274+ fun returnEmptyPage_WhenAllFiltered () {
275+ // given
276+ val user = createTestMember(1L , " 사용자A" )
277+ val existingIds = listOf (2L , 3L )
278+
279+ `when `(timeZoneService.getCurrentTimeSlot(null )).thenReturn(" 10:00" )
280+ `when `(timeZoneService.getTimeSlotRangeInUTC(" 10:00" , null )).thenReturn(
281+ Pair (LocalDateTime .now(), LocalDateTime .now().plusHours(12 ))
282+ )
283+ `when `(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds)
284+
285+ // 모두 차단
286+ `when `(exclusionService.getBlockedMemberIds(user)).thenReturn(setOf (2L , 3L ))
287+ `when `(bucketService.getMembersByIds(existingIds)).thenReturn(emptyList())
288+
289+ // when
290+ val result = codeTimeService.getCodeTimeRecommendation(user, 0 , 10 )
291+
292+ // then
293+ assertEquals(0 , result.content.size)
294+ assertEquals(0 , result.totalElements)
295+ }
296+
297+ @Test
298+ @DisplayName(" 페이징이 올바르게 적용된다" )
299+ fun applyPaginationCorrectly () {
300+ // given
301+ val user = createTestMember(1L , " 사용자A" )
302+ val existingIds = listOf (2L , 3L , 4L , 5L , 6L )
303+ val existingMembers = (2L .. 6L ).map { createTestMember(it, " 추천$it " ) }
304+
305+ `when `(timeZoneService.getCurrentTimeSlot(null )).thenReturn(" 10:00" )
306+ `when `(timeZoneService.getTimeSlotRangeInUTC(" 10:00" , null )).thenReturn(
307+ Pair (LocalDateTime .now(), LocalDateTime .now().plusHours(12 ))
308+ )
309+ `when `(historyService.getCodeTimeIdsByTimeRange(any(), any(), any(), any())).thenReturn(existingIds)
310+
311+ `when `(exclusionService.getBlockedMemberIds(user)).thenReturn(emptySet())
312+ `when `(bucketService.getMembersByIds(existingIds)).thenReturn(existingMembers)
313+
314+ // when
315+ val page = 0
316+ val size = 3
317+ val result = codeTimeService.getCodeTimeRecommendation(user, page, size)
318+
319+ // then
320+ assertEquals(5 , result.content.size) // 페이징은 PageImpl에서 처리되므로 전체 반환
321+ assertEquals(5 , result.totalElements)
322+ }
323+
324+ // Helper methods
325+
326+ private fun createTestMember (
327+ id : Long ,
328+ name : String ,
329+ status : MemberStatus = MemberStatus .DONE
330+ ): Member {
331+ val profile = Profile (
332+ id = id,
333+ codeName = name,
334+ bigCity = " 서울" ,
335+ smallCity = " 강남구" ,
336+ birthDate = LocalDate .of(1990 , 1 , 1 )
337+ )
338+
339+ val member = Member (
340+ id = id,
341+ oauthId = " oauth-$id " ,
342+ oauthType = OauthType .KAKAO ,
343+ memberStatus = status,
344+ email = " $name @test.com" ,
345+ profile = profile
346+ )
347+
348+ profile.member = member
349+
350+ return member
351+ }
352+ }
0 commit comments