From ad46fd608ee87433da52137bad5249ceb71c219b Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Sun, 24 May 2026 13:52:14 +0900 Subject: [PATCH 1/4] =?UTF-8?q?Refactor:=20OAuth=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/auth/OAuth2UserService.kt | 71 ++++++++----------- 1 file changed, 30 insertions(+), 41 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/auth/OAuth2UserService.kt b/src/main/kotlin/com/tripsync/application/auth/OAuth2UserService.kt index 23ca1c3..ac056e2 100644 --- a/src/main/kotlin/com/tripsync/application/auth/OAuth2UserService.kt +++ b/src/main/kotlin/com/tripsync/application/auth/OAuth2UserService.kt @@ -4,12 +4,9 @@ import com.tripsync.common.dto.ApiResponse import com.tripsync.common.exception.DomainException import com.tripsync.domain.entity.User import com.tripsync.domain.enums.AuthProvider -import com.tripsync.domain.enums.YnFlag import com.tripsync.domain.repository.UserRepository -import mu.KotlinLogging import org.springframework.http.HttpStatus import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest import org.springframework.security.oauth2.core.user.OAuth2User import org.springframework.stereotype.Service @@ -18,8 +15,6 @@ class OAuth2UserService( private val userRepository: UserRepository, private val jwtTokenProvider: JwtTokenProvider, ) : DefaultOAuth2UserService() { - private val logger = KotlinLogging.logger {} - fun processGoogleLogin(oAuth2User: OAuth2User): ApiResponse> { val email = oAuth2User.getAttribute("email") ?: throw DomainException(HttpStatus.BAD_REQUEST, "OAUTH_EMAIL_MISSING", "이메일 정보를 가져올 수 없습니다.") @@ -28,33 +23,14 @@ class OAuth2UserService( val providerId = oAuth2User.getAttribute("sub") ?: throw DomainException(HttpStatus.BAD_REQUEST, "OAUTH_ID_MISSING", "사용자 ID를 가져올 수 없습니다.") - val existingUser = userRepository.findByAuthProviderAndProviderUserId(AuthProvider.GOOGLE, providerId) - - val user = if (existingUser != null) { - existingUser - } else { - userRepository.save( - User( - nickname = name, - email = email, - authProvider = AuthProvider.GOOGLE, - providerUserId = providerId, - profileImageUrl = picture, - ) - ) - } - - val token = jwtTokenProvider.generateToken(user.id, user.isGuest) - @Suppress("UNCHECKED_CAST") - return ApiResponse.ok( - mapOf( - "token" to token, - "userId" to user.id, - "nickname" to user.nickname, - "email" to (user.email ?: ""), - "authProvider" to user.authProvider.name, - ) as Map + val user = findOrCreateOAuthUser( + provider = AuthProvider.GOOGLE, + providerId = providerId, + nickname = name, + email = email, + profileImageUrl = picture, ) + return authResponse(user) } fun processKakaoLogin(oAuth2User: OAuth2User): ApiResponse> { @@ -68,24 +44,37 @@ class OAuth2UserService( val providerId = attributes["id"]?.toString() ?: throw DomainException(HttpStatus.BAD_REQUEST, "OAUTH_ID_MISSING", "사용자 ID를 가져올 수 없습니다.") - val existingUser = userRepository.findByAuthProviderAndProviderUserId(AuthProvider.KAKAO, providerId) + val user = findOrCreateOAuthUser( + provider = AuthProvider.KAKAO, + providerId = providerId, + nickname = nickname, + email = email, + profileImageUrl = profileImage, + ) + return authResponse(user) + } - val user = if (existingUser != null) { - existingUser - } else { - userRepository.save( + private fun findOrCreateOAuthUser( + provider: AuthProvider, + providerId: String, + nickname: String, + email: String?, + profileImageUrl: String?, + ): User { + return userRepository.findByAuthProviderAndProviderUserId(provider, providerId) + ?: userRepository.save( User( nickname = nickname, email = email, - authProvider = AuthProvider.KAKAO, + authProvider = provider, providerUserId = providerId, - profileImageUrl = profileImage, + profileImageUrl = profileImageUrl, ) ) - } + } + private fun authResponse(user: User): ApiResponse> { val token = jwtTokenProvider.generateToken(user.id, user.isGuest) - @Suppress("UNCHECKED_CAST") return ApiResponse.ok( mapOf( "token" to token, @@ -93,7 +82,7 @@ class OAuth2UserService( "nickname" to user.nickname, "email" to (user.email ?: ""), "authProvider" to user.authProvider.name, - ) as Map + ) ) } } From 07c9f31d905ed67b6a39b3c57e99757d8c8a08b3 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Sun, 24 May 2026 13:52:14 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Refactor:=20=EC=9D=BC=EC=A0=95=20LLM=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=BA=90?= =?UTF-8?q?=EC=8A=A4=ED=8C=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tripsync/application/schedule/ScheduleResponseMapper.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt index 85df105..c3e62d2 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt @@ -139,9 +139,8 @@ class ScheduleResponseMapper( return formatStoredSchedule(schedule).filterKeys { it in publicKeys } } - @Suppress("UNCHECKED_CAST") private fun formatLlmMetadata(schedule: Schedule): Map { - val metadata = schedule.generationInput["llm"] as? Map ?: emptyMap() + val metadata = schedule.generationInput["llm"] as? Map<*, *> ?: emptyMap() val provider = schedule.llmProvider ?: metadata["provider"] return mapOf( "provider" to provider, From ee9c5a4988c9a286d7bcdfbe52c7eab935b28c82 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Fri, 29 May 2026 16:35:54 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=9D=B8=EC=A6=9D=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=9D=91=EB=8B=B5=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/tripsync/web/auth/AuthController.kt | 7 ++-- .../kotlin/com/tripsync/AuthContractTests.kt | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/tripsync/web/auth/AuthController.kt b/src/main/kotlin/com/tripsync/web/auth/AuthController.kt index 19ac037..25825b8 100644 --- a/src/main/kotlin/com/tripsync/web/auth/AuthController.kt +++ b/src/main/kotlin/com/tripsync/web/auth/AuthController.kt @@ -5,6 +5,7 @@ import com.tripsync.application.auth.JwtAuthenticationFilter import com.tripsync.application.auth.JwtTokenProvider import com.tripsync.application.auth.OAuthSessionService import com.tripsync.common.dto.ApiResponse +import com.tripsync.common.exception.DomainException import com.tripsync.common.security.CurrentUser import com.tripsync.domain.entity.User import com.tripsync.domain.enums.AuthProvider @@ -37,7 +38,7 @@ class AuthController( fun register(@Valid @RequestBody dto: RegisterDto, response: HttpServletResponse): ApiResponse> { val email = dto.email.trim().lowercase() if (userRepository.findByEmailAndDelYn(email, YnFlag.N) != null) { - return ApiResponse.error("INVALID_REQUEST", "이미 사용 중인 이메일입니다.") + throw DomainException(HttpStatus.CONFLICT, "INVALID_REQUEST", "이미 사용 중인 이메일입니다.") } val user = userRepository.save( @@ -57,10 +58,10 @@ class AuthController( @PostMapping("/login") fun login(@Valid @RequestBody dto: LoginDto, response: HttpServletResponse): ApiResponse> { val user = userRepository.findByEmailAndDelYn(dto.email.trim().lowercase(), YnFlag.N) - ?: return ApiResponse.error("UNAUTHORIZED", "이메일 또는 비밀번호가 올바르지 않습니다.") + ?: throw DomainException(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "이메일 또는 비밀번호가 올바르지 않습니다.") if (user.authProvider != AuthProvider.LOCAL || user.passwordHash == null || !passwordEncoder.matches(dto.password, user.passwordHash)) { - return ApiResponse.error("UNAUTHORIZED", "이메일 또는 비밀번호가 올바르지 않습니다.") + throw DomainException(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "이메일 또는 비밀번호가 올바르지 않습니다.") } val token = issueSessionCookie(user, response) diff --git a/src/test/kotlin/com/tripsync/AuthContractTests.kt b/src/test/kotlin/com/tripsync/AuthContractTests.kt index 9be696b..d2ab84c 100644 --- a/src/test/kotlin/com/tripsync/AuthContractTests.kt +++ b/src/test/kotlin/com/tripsync/AuthContractTests.kt @@ -67,6 +67,43 @@ class AuthContractTests( } } + @Test + fun `duplicate register returns conflict instead of success false payload`() { + val email = "duplicate-${System.nanoTime()}@example.com" + + mockMvc.post("/auth/register") { + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"password123","nickname":"중복"}""" + }.andExpect { + status { isCreated() } + jsonPath("$.success") { value(true) } + } + + mockMvc.post("/auth/register") { + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"password123","nickname":"중복"}""" + }.andExpect { + status { isConflict() } + jsonPath("$.success") { value(false) } + jsonPath("$.error.message") { value("이미 사용 중인 이메일입니다.") } + } + } + + @Test + fun `invalid local login returns unauthorized instead of success false payload`() { + val email = "login-fail-${System.nanoTime()}@example.com" + registerSession(email, "로그인실패") + + mockMvc.post("/auth/login") { + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"wrong-password"}""" + }.andExpect { + status { isUnauthorized() } + jsonPath("$.success") { value(false) } + jsonPath("$.error.message") { value("이메일 또는 비밀번호가 올바르지 않습니다.") } + } + } + @Test fun `cookie session authenticates me and tpti submit then public share`() { val guest = mockMvc.post("/auth/guest") { From 8acd8828860f369be93b14e3c834e90b54cb9d53 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Fri, 29 May 2026 16:35:54 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EC=9D=BC=EC=A0=95=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EB=A9=94=ED=83=80=EC=99=80=20=EC=B0=B8=EA=B3=A0?= =?UTF-8?q?=EA=B5=B0=20=EB=AC=B8=EA=B5=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persona/PersonaValidationService.kt | 57 +++++++++-- .../schedule/ScheduleResponseMapper.kt | 18 +++- .../persona/PersonaValidationServiceTest.kt | 96 +++++++++++++++++++ .../schedule/ScheduleResponseMapperTest.kt | 28 ++++++ 4 files changed, 189 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/persona/PersonaValidationService.kt b/src/main/kotlin/com/tripsync/application/persona/PersonaValidationService.kt index fe99509..acbcde4 100644 --- a/src/main/kotlin/com/tripsync/application/persona/PersonaValidationService.kt +++ b/src/main/kotlin/com/tripsync/application/persona/PersonaValidationService.kt @@ -84,7 +84,7 @@ class PersonaValidationService( (slotScores.average() * 100).toInt().coerceIn(0, 100) } - val positiveSignals = buildPositiveSignals(option, members) + val positiveSignals = buildPositiveSignals(option, acceptanceScore) val objections = buildObjections(option, members) val persuasions = buildPersuasionPoints(option) @@ -107,10 +107,27 @@ class PersonaValidationService( } } - private fun buildPositiveSignals(option: ScheduleOptionForValidation, members: List): List { + private fun buildPositiveSignals(option: ScheduleOptionForValidation, acceptanceScore: Int): List { + val commonSlotCount = option.slots.count { it.slotType == SlotType.COMMON } + val leadingAxes = option.slots + .flatMap { slot -> slot.scores.leadingAxisLabels() } + .groupingBy { it } + .eachCount() + .entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .map { it.key } + .take(2) + + val preferenceSummary = when (leadingAxes.size) { + 0 -> "무리 없는 일정 밀도와 균형 잡힌 장소 구성이 좋게 평가됩니다." + 1 -> "${leadingAxes[0]} 성향의 여행자에게 일정 구성이 잘 맞습니다." + else -> "${leadingAxes.joinToString("·")} 성향의 여행자에게 일정 구성이 잘 맞습니다." + } + return listOf( - "${members.size}명의 취향이 ${option.slots.count { it.slotType == SlotType.COMMON }}개 공통 슬롯에서 조화를 이룹니다.", - "그룹 만족도 ${option.groupSatisfaction}점으로 안정적인 선택입니다.", + preferenceSummary, + "${commonSlotCount}개 공통 코스로 동행자와 함께 움직이기 쉽습니다.", + "참고군 기준 수용도가 ${acceptanceScore}점으로 안정적입니다.", ) } @@ -123,8 +140,34 @@ class PersonaValidationService( } private fun buildPersuasionPoints(option: ScheduleOptionForValidation): List { - return option.slots.filter { it.slotType == SlotType.PERSONAL }.map { slot -> - slot.reasonText.trim().trimEnd('.', '!', '?', '。') - }.distinct() + return option.slots + .filter { it.slotType == SlotType.PERSONAL } + .mapNotNull { slot -> slot.reasonText.toPersonaFriendlyReason() } + .distinct() + } + + private fun AxisScores.leadingAxisLabels(): List { + return listOf( + mobility to "이동을 즐기는", + photo to "사진 기록을 중시하는", + budget to "예산 효율을 보는", + theme to "테마 경험을 선호하는", + ) + .filter { (score, _) -> score >= 60 } + .sortedByDescending { (score, _) -> score } + .map { (_, label) -> label } + .take(2) + } + + private fun String.toPersonaFriendlyReason(): String? { + val cleaned = trim().trimEnd('.', '!', '?', '。').takeIf { it.isNotBlank() } ?: return null + return when { + cleaned.contains("사진") || cleaned.contains("기록") -> "사진을 남기기 좋은 장소가 포함되어 기록형 여행자에게 매력적입니다." + cleaned.contains("예산") -> "부담 없는 비용 흐름이라 실속형 여행자도 수용하기 좋습니다." + cleaned.contains("테마") -> "여행 테마가 분명해 취향이 비슷한 참고군에게 설득력이 있습니다." + cleaned.contains("이동") || cleaned.contains("활동성") -> "이동 부담을 고려한 구성이라 활동형 여행자도 따라가기 쉽습니다." + cleaned.contains("취향 반영") -> null + else -> cleaned + } } } diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt index c3e62d2..2312b61 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt @@ -175,6 +175,8 @@ class ScheduleResponseMapper( return mapOf( "imageUrl" to image.url, "imageSource" to image.source, + "fallbackImageUrl" to image.fallbackUrl, + "fallbackImageSource" to image.fallbackSource, "isRegionalBenefit" to isRegionalBenefit(place?.metadataTags, metric), "popularity" to formatPopularity(metric, place?.metadataTags), ) @@ -188,10 +190,13 @@ class ScheduleResponseMapper( private fun formatImage(place: Place?, metric: ExternalPopularityMetric?, placeId: Long): PlaceImage { val tourApiImage = place?.imageUrl?.trim()?.takeIf { it.isNotBlank() } - if (tourApiImage != null) return PlaceImage(tourApiImage, "tourapi") val hasGooglePhoto = metric?.googlePhotoReference?.isNotBlank() == true - return if (hasGooglePhoto) { - PlaceImage("${apiBaseUrl.trimEnd('/')}/places/$placeId/photo", "google_places") + val googleImage = if (hasGooglePhoto) "${apiBaseUrl.trimEnd('/')}/places/$placeId/photo" else null + + return if (tourApiImage != null) { + PlaceImage(tourApiImage, "tourapi", googleImage, if (googleImage != null) "google_places" else null) + } else if (googleImage != null) { + PlaceImage(googleImage, "google_places") } else { PlaceImage(null, null) } @@ -215,6 +220,11 @@ class ScheduleResponseMapper( "role" to role, "label" to label, "hasExternalSignal" to (score != null), + "normalizedPopularityScore" to score, + "naverSearchTrendScore" to metric?.naverSearchTrendScore, + "googleRating" to metric?.googleRating?.toDouble(), + "googleUserRatingCount" to metric?.googleUserRatingCount, + "sourceUpdatedAt" to metric?.updatedAt?.toString(), ) } @@ -231,5 +241,7 @@ class ScheduleResponseMapper( private data class PlaceImage( val url: String?, val source: String?, + val fallbackUrl: String? = null, + val fallbackSource: String? = null, ) } diff --git a/src/test/kotlin/com/tripsync/application/persona/PersonaValidationServiceTest.kt b/src/test/kotlin/com/tripsync/application/persona/PersonaValidationServiceTest.kt index 630f093..7f7efaf 100644 --- a/src/test/kotlin/com/tripsync/application/persona/PersonaValidationServiceTest.kt +++ b/src/test/kotlin/com/tripsync/application/persona/PersonaValidationServiceTest.kt @@ -56,6 +56,54 @@ class PersonaValidationServiceTest { assertTrue(result.isEmpty()) } + @Test + fun `validation explains why similar personas would like the option`() { + val service = PersonaValidationService( + personaVectorServiceWith( + PersonaVectorService.PersonaVectorData( + uuid = UUID.randomUUID(), + scores = AxisScores(mobility = 80, photo = 75, budget = 55, theme = 45), + summary = "사진과 이동을 즐기는 참고군", + ) + ) + ) + + val result = service.validateOptions( + options = listOf( + PersonaValidationService.ScheduleOptionForValidation( + optionType = ScheduleOptionType.INDIVIDUAL, + groupSatisfaction = 85, + slots = listOf( + PersonaValidationService.ScheduleSlotForValidation( + slotType = SlotType.COMMON, + reasonText = "공통 취향 반영", + scores = AxisScores(mobility = 80, photo = 75, budget = 40, theme = 50), + ), + PersonaValidationService.ScheduleSlotForValidation( + slotType = SlotType.PERSONAL, + reasonText = "사진 취향 반영", + scores = AxisScores(mobility = 65, photo = 90, budget = 50, theme = 55), + ), + ), + satisfactionByUser = listOf( + PersonaValidationService.SatisfactionForValidation(userId = 1L, score = 85), + ), + ) + ), + members = listOf( + PersonaValidationService.MemberSnapshot( + userId = 1L, + scores = AxisScores(mobility = 82, photo = 76, budget = 50, theme = 45), + ) + ), + )[ScheduleOptionType.INDIVIDUAL]!! + + assertTrue(result.topPositiveSignals.any { it.contains("사진 기록") || it.contains("이동") }) + assertTrue(result.topPositiveSignals.any { it.contains("${result.personaAcceptanceScore}점") }) + assertTrue(result.topPositiveSignals.none { it.contains("취향 반영") }) + assertEquals("사진을 남기기 좋은 장소가 포함되어 기록형 여행자에게 매력적입니다.", result.persuasionPoints.single()) + } + private fun optionWithSlotScores(scores: AxisScores): PersonaValidationService.ScheduleOptionForValidation { return PersonaValidationService.ScheduleOptionForValidation( optionType = ScheduleOptionType.BALANCED, @@ -83,4 +131,52 @@ class PersonaValidationServiceTest { field.set(service, vectors.toList()) return service } + + @Test + fun `personal reason text is converted to persona benefit copy without exposing member names`() { + val service = PersonaValidationService( + personaVectorServiceWith( + PersonaVectorService.PersonaVectorData( + uuid = UUID.randomUUID(), + scores = AxisScores(mobility = 60, photo = 70, budget = 50, theme = 65), + summary = "기록형 참고군", + ) + ) + ) + + val result = service.validateOptions( + options = listOf( + PersonaValidationService.ScheduleOptionForValidation( + optionType = ScheduleOptionType.BALANCED, + groupSatisfaction = 79, + slots = listOf( + PersonaValidationService.ScheduleSlotForValidation( + slotType = SlotType.COMMON, + reasonText = "그룹 전원의 평균 취향 반영", + scores = AxisScores(mobility = 65, photo = 70, budget = 50, theme = 60), + ), + PersonaValidationService.ScheduleSlotForValidation( + slotType = SlotType.PERSONAL, + reasonText = "hee의 기록 취향 반영", + scores = AxisScores(mobility = 55, photo = 75, budget = 50, theme = 65), + ), + ), + satisfactionByUser = listOf( + PersonaValidationService.SatisfactionForValidation(userId = 1, score = 79), + ), + ), + ), + members = listOf( + PersonaValidationService.MemberSnapshot( + userId = 1, + scores = AxisScores(mobility = 60, photo = 70, budget = 50, theme = 65), + ), + ), + )[ScheduleOptionType.BALANCED]!! + + assertTrue(result.topPositiveSignals.any { it.contains(result.personaAcceptanceScore.toString()) }) + assertTrue(result.topPositiveSignals.none { it.contains("그룹 만족도") }) + assertEquals(listOf("사진을 남기기 좋은 장소가 포함되어 기록형 여행자에게 매력적입니다."), result.persuasionPoints) + assertTrue(result.persuasionPoints.none { it.contains("hee") || it.contains("취향 반영") }) + } } diff --git a/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt b/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt index 43510ff..594c117 100644 --- a/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt +++ b/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt @@ -60,6 +60,9 @@ class ScheduleResponseMapperTest { val metric = ExternalPopularityMetric( place = place, normalizedPopularityScore = 82, + naverSearchTrendScore = 64, + googleRating = BigDecimal("4.5"), + googleUserRatingCount = 182, collectedAt = Instant.parse("2026-05-20T00:00:00Z"), ) `when`(externalPopularityMetricRepository.findByPlaceId(place.id)).thenReturn(metric) @@ -69,8 +72,15 @@ class ScheduleResponseMapperTest { assertEquals("https://tour.example/image.jpg", response["imageUrl"]) assertEquals("tourapi", response["imageSource"]) + assertNull(response["fallbackImageUrl"]) + assertNull(response["fallbackImageSource"]) assertEquals("popular_anchor", popularity["role"]) assertFalse(popularity.containsKey("score")) + assertEquals(82, popularity["normalizedPopularityScore"]) + assertEquals(64, popularity["naverSearchTrendScore"]) + assertEquals(4.5, popularity["googleRating"]) + assertEquals(182, popularity["googleUserRatingCount"]) + assertEquals(metric.updatedAt.toString(), popularity["sourceUpdatedAt"]) } @Test @@ -89,6 +99,24 @@ class ScheduleResponseMapperTest { assertEquals("google_places", response["imageSource"]) } + @Test + fun `place response includes Google fallback image when TourAPI image exists`() { + val place = place(imageUrl = "https://tour.example/image.jpg") + val metric = ExternalPopularityMetric( + place = place, + googlePhotoReference = "photo-ref", + collectedAt = Instant.parse("2026-05-20T00:00:00Z"), + ) + `when`(externalPopularityMetricRepository.findByPlaceId(place.id)).thenReturn(metric) + + val response = mapper.formatPlace(place, place.id, place.name, place.address) + + assertEquals("https://tour.example/image.jpg", response["imageUrl"]) + assertEquals("tourapi", response["imageSource"]) + assertEquals("http://localhost:8080/api/places/${place.id}/photo", response["fallbackImageUrl"]) + assertEquals("google_places", response["fallbackImageSource"]) + } + @Test fun `place response marks external signal missing without excluding place`() { val place = place(imageUrl = null)