diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt index b4e19432..04948252 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt @@ -4,14 +4,22 @@ import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.content.recommendation.dto.AudioRecommendationsResponse import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerResponse +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component @Component class AudioRecommendationFacade( - private val queryService: AudioRecommendationQueryService + private val queryService: AudioRecommendationQueryService, + private val subBannerService: SubBannerService, + @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { fun getRecommendations(member: Member?, lang: Lang): AudioRecommendationsResponse { val result = queryService.getRecommendations(member, lang) - return AudioRecommendationsResponse.from(result) + val subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.CONTENT_RECOMMENDATION) + .map { SubBannerResponse.from(it, cloudFrontHost) } + return AudioRecommendationsResponse.from(result, subBanners) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt index a3d18dce..a936abfa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt @@ -6,9 +6,11 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries +import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerResponse data class AudioRecommendationsResponse( val banners: List, + val subBanners: List, val originalSeries: List, val latestAudios: List, val newAndHotAudios: List, @@ -18,9 +20,10 @@ data class AudioRecommendationsResponse( val recommendedAudios: List ) { companion object { - fun from(recommendations: AudioRecommendations): AudioRecommendationsResponse { + fun from(recommendations: AudioRecommendations, subBanners: List): AudioRecommendationsResponse { return AudioRecommendationsResponse( banners = recommendations.banners.map(RecommendationBannerResponse::from), + subBanners = subBanners, originalSeries = recommendations.originalSeries.map(OriginalSeriesResponse::from), latestAudios = recommendations.latestAudios.map(AudioCardResponse::from), newAndHotAudios = recommendations.newAndHotAudios.map(AudioCardResponse::from), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt index a1f86435..2e359e79 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt @@ -30,6 +30,9 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendatio import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomePopularCommunityRecommendationRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerResponse import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component @@ -39,6 +42,7 @@ import java.time.LocalDateTime class HomeRecommendationFacade( private val queryService: HomeRecommendationQueryService, private val memberContentPreferenceService: MemberContentPreferenceService, + private val subBannerService: SubBannerService, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { @@ -68,6 +72,8 @@ class HomeRecommendationFacade( lang = bannerLang ) .map { RecommendationBannerResponse.from(it.toBanner()) }, + subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION) + .map { SubBannerResponse.from(it, cloudFrontHost) }, recentlyActiveCreators = queryService.findRecentlyActiveCreators( HOME_ACTIVE_CREATOR_LIMIT, member?.id, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt index cc19db1a..77516e0d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.home.dto.recommendation import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse +import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerResponse import java.time.LocalDateTime import java.time.ZoneOffset @@ -20,6 +21,7 @@ internal fun profileImageUrl(cloudFrontHost: String, path: String?): String { data class HomeRecommendationResponse( val lives: List, val banners: List, + val subBanners: List, val recentlyActiveCreators: List, val recentDebutCreators: List, val firstAudioContents: List, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerResponse.kt new file mode 100644 index 00000000..1a47e413 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerResponse.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.v2.subbanner.dto + +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement + +data class SubBannerResponse( + val id: Long, + val placement: SubBannerPlacement, + val imageUrl: String, + val webUrl: String?, + val androidDeepLink: String?, + val iosDeepLink: String? +) { + companion object { + fun from(banner: SubBanner, cloudFrontHost: String): SubBannerResponse = SubBannerResponse( + id = requireNotNull(banner.id), + placement = banner.placement, + imageUrl = "$cloudFrontHost/${banner.imagePath}", + webUrl = banner.webUrl, + androidDeepLink = banner.androidDeepLink, + iosDeepLink = banner.iosDeepLink + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt index 50992a80..b0c59109 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt @@ -67,6 +67,7 @@ class AudioRecommendationControllerTest @Autowired constructor( .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.banners").isArray) + .andExpect(jsonPath("$.data.subBanners").isEmpty) .andExpect(jsonPath("$.data.originalSeries").isArray) .andExpect(jsonPath("$.data.latestAudios").isArray) .andExpect(jsonPath("$.data.recommendedAudios").isArray) @@ -93,6 +94,7 @@ class AudioRecommendationControllerTest @Autowired constructor( private fun emptyResponse(): AudioRecommendationsResponse { return AudioRecommendationsResponse( banners = emptyList(), + subBanners = emptyList(), originalSeries = emptyList(), latestAudios = emptyList(), newAndHotAudios = emptyList(), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt index 7154a39f..39874698 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt @@ -49,6 +49,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor( mockMvc.perform(get("/api/v2/audio/recommendations")) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.subBanners").isEmpty) .andExpect(jsonPath("$.data.originalSeries").isArray) .andExpect(jsonPath("$.data.originalSeries[0].seriesId").value(fixture.seriesId)) .andExpect(jsonPath("$.data.latestAudios").isArray) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt index 55799fd0..3c24bdda 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -16,7 +17,7 @@ import org.mockito.Mockito class AudioRecommendationFacadeTest { private val objectMapper = jacksonObjectMapper() private val queryService = Mockito.mock(AudioRecommendationQueryService::class.java) - private val facade = AudioRecommendationFacade(queryService) + private val facade = AudioRecommendationFacade(queryService, Mockito.mock(SubBannerService::class.java), "https://cdn.test") @Test @DisplayName("facade는 도메인 추천 결과를 모든 공개 응답 필드로 변환한다") @@ -39,6 +40,7 @@ class AudioRecommendationFacadeTest { assertEquals(false, response.recommendedAudios[0].isPointAvailable) val json = objectMapper.readTree(objectMapper.writeValueAsString(response)) + assertEquals(objectMapper.readTree("[]"), json["subBanners"]) assertEquals(false, json["latestAudios"][0]["isAdult"].asBoolean()) assertEquals(false, json["latestAudios"][0]["isPointAvailable"].asBoolean()) assertEquals(true, json["latestAudios"][0]["isFirstContent"].asBoolean()) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt index 85fec011..069deefd 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.Recomm import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertThrows @@ -59,7 +60,8 @@ class HomeRecommendationControllerTest @Autowired constructor( private val memberRepository: MemberRepository, private val creatorFollowingRepository: CreatorFollowingRepository, private val entityManager: EntityManager, - private val jdbcTemplate: JdbcTemplate + private val jdbcTemplate: JdbcTemplate, + private val subBannerService: SubBannerService ) { @MockBean private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService @@ -372,6 +374,7 @@ class HomeRecommendationControllerTest @Autowired constructor( val facade = HomeRecommendationFacade( failingQueryService, preferenceService, + subBannerService, "https://cdn.test" ) Mockito.`when`(failingQueryService.findLiveRecommendations(limit = 20, memberId = null, lang = Lang.KO)) @@ -414,6 +417,7 @@ class HomeRecommendationControllerTest @Autowired constructor( val facade = HomeRecommendationFacade( failingQueryService, preferenceService, + subBannerService, "https://cdn.test" ) Mockito.`when`(preferenceService.canViewAdultContent(member)).thenReturn(false) @@ -448,6 +452,7 @@ class HomeRecommendationControllerTest @Autowired constructor( val facade = HomeRecommendationFacade( failingQueryService, preferenceService, + subBannerService, "https://cdn.test" ) Mockito.`when`(preferenceService.canViewAdultContent(member)).thenReturn(false) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt index f8cedadb..f927b102 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt @@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRec import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName @@ -40,6 +41,7 @@ class HomeRecommendationFacadeTest { private val facade = HomeRecommendationFacade( queryService, preferenceService, + Mockito.mock(SubBannerService::class.java), "https://cdn.test" ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt index ec6fda32..a805ee3c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt @@ -14,6 +14,7 @@ class HomeRecommendationResponseTest { fun shouldSerializeNewHomeRecommendationFields() { val response = HomeRecommendationResponse( lives = emptyList(), + subBanners = emptyList(), banners = listOf( RecommendationBannerResponse( imageUrl = "https://cdn.test/banner.png", @@ -101,6 +102,7 @@ class HomeRecommendationResponseTest { val json = objectMapper.readTree(objectMapper.writeValueAsString(response)) + assertEquals(objectMapper.readTree("[]"), json["subBanners"]) assertEquals("https://cdn.test/banner.png", json["banners"][0]["imageUrl"].asText()) assertEquals(true, json["banners"][0]["eventItem"].isNull) assertEquals(11L, json["banners"][0]["creatorId"].asLong()) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/RecommendationSubBannerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/RecommendationSubBannerIntegrationTest.kt new file mode 100644 index 00000000..7fb3b9dc --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/RecommendationSubBannerIntegrationTest.kt @@ -0,0 +1,525 @@ +package kr.co.vividnext.sodalive.v2.subbanner.adapter.`in`.web + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner +import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType +import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.EnumSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.support.TransactionTemplate +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping +import java.time.LocalDateTime +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import javax.persistence.EntityManager + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "cloud.aws.s3.bucket=test-bucket", + "spring.datasource.url=jdbc:h2:mem:public-sub-banner-http;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class RecommendationSubBannerIntegrationTest @Autowired constructor( + private val restTemplate: TestRestTemplate, + private val objectMapper: ObjectMapper, + private val repository: SubBannerRepository, + private val jdbcTemplate: JdbcTemplate, + private val transactionTemplate: TransactionTemplate, + private val entityManager: EntityManager, + private val memberRepository: MemberRepository, + private val tokenProvider: TokenProvider, + private val tokenRepository: MemberTokenRepository, + private val time: TestTime, + private val requestMappingHandlerMapping: RequestMappingHandlerMapping +) { + @LocalServerPort + private var port: Int = 0 + + @MockBean + private lateinit var s3Uploader: S3Uploader + + private val memberIds = mutableListOf() + + @BeforeEach + fun setUp() { + jdbcTemplate.update("delete from sub_banner") + jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN start_date TIMESTAMP(0)") + jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN end_date TIMESTAMP(0)") + jdbcTemplate.update("insert into audio_content_main_tab (id, title, is_active) values (2, 'recommendation', true)") + transactionTemplate.execute { + entityManager.persist(legacyBanner()) + entityManager.persist(legacyBanner().apply { tab = entityManager.getReference(AudioContentMainTab::class.java, 2L) }) + entityManager.flush() + entityManager.clear() + } + time.set(LocalDateTime.parse("2026-10-29T23:59:59.999999999")) + } + + @AfterEach + fun cleanUp() { + jdbcTemplate.update("delete from sub_banner") + jdbcTemplate.update("delete from content_banner") + jdbcTemplate.update("delete from audio_content_main_tab") + tokenRepository.deleteAll() + memberRepository.deleteAllById(memberIds) + memberIds.clear() + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("익명 추천 GET은 기존 응답과 배너를 보존하며 두 placement의 빈 subBanners 배열을 반환한다") + fun shouldReturnEmptyArrayForEitherPlacement(placement: SubBannerPlacement) { + val response = send(recommendationPath(placement)) + evidence("empty $placement", response) + assertEquals(emptyList(), subBanners(response, placement).toList()) + assertEquals(1, time.calls.get()) + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("공개 목록만 현재 기간을 필터링하고 관리자는 미래·만료·무기한을 모두 유지한다") + fun shouldReturnOnlyVisiblePlacementRowsInIdOrderWithoutDates(placement: SubBannerPlacement) { + val now = LocalDateTime.parse("2026-10-29T23:59:59") + val unbounded = save(banner(placement)) + val future = save(banner(placement, start = now.plusSeconds(1))) + val untilNow = save(banner(placement, end = now)) + val expired = save(banner(placement, end = now.minusSeconds(1))) + val fromNow = save(banner(placement, start = now)) + val instant = save(banner(placement, start = now, end = now)) + val other = SubBannerPlacement.values().first { it != placement } + save(banner(other)) + val response = send(recommendationPath(placement)) + evidence("visible $placement", response) + assertEquals( + listOf(unbounded, untilNow, fromNow, instant).map(::publicTree), + subBanners(response, placement).toList() + ) + assertEquals(1, time.calls.get()) + val admin = success(send("$ADMIN_PATH?placement=$placement", token = tokenFor(MemberRole.ADMIN))) + assertEquals(listOf(unbounded, future, untilNow, expired, fromNow, instant).map { it.id }, ids(admin)) + assertTrue(admin.all { it.has("startDate") && it.has("endDate") }) + Mockito.verifyNoInteractions(s3Uploader) + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("실제 관리자 DELETE는 모든 기간의 행을 보존하며 익명·관리자 목록에서 비활성 행을 제외한다") + fun shouldHideSoftDeletedRowsFromPublicAndAdminWhileRetainingDatabaseRows(placement: SubBannerPlacement) { + val now = LocalDateTime.parse("2026-10-29T23:59:59") + val periods = listOf(null to null, now.minusMinutes(1) to now, now.plusSeconds(1) to null, null to now.minusSeconds(1)) + val deleted = periods.map { (start, end) -> save(banner(placement, start, end)) } + val active = periods.map { (start, end) -> save(banner(placement, start, end)) } + val other = save(banner(SubBannerPlacement.values().first { it != placement })) + val before = storedRows() + val token = tokenFor(MemberRole.ADMIN) + + deleted.forEach { banner -> + val response = send("$ADMIN_PATH/${banner.id}", HttpMethod.DELETE, token) + evidence("soft-delete $placement", response) + assertEquals(HttpStatus.OK, response.statusCode, response.body) + assertEquals( + objectMapper.readTree("""{"success":true,"message":null,"data":null,"errorProperty":null}"""), + objectMapper.readTree(response.body) + ) + } + + val retained = storedRows() + assertEquals(before.size, retained.size, "Soft delete must retain every original row") + before.zip(retained).forEach { (original, stored) -> + if (deleted.any { it.id == (stored["id"] as Number).toLong() }) { + assertEquals(false, stored["is_active"]) + assertEquals(original - setOf("is_active", "updated_at"), stored - setOf("is_active", "updated_at")) + } else { + assertEquals(true, stored["is_active"]) + assertEquals(original, stored) + } + } + println("DB EVIDENCE $placement retained=${retained.size} inactive=${retained.count { it["is_active"] == false }}") + val publicResponse = send(recommendationPath(placement)) + evidence("after soft-delete $placement", publicResponse) + assertEquals(active.take(2).map(::publicTree), subBanners(publicResponse, placement).toList()) + assertEquals(1, time.calls.get()) + val admin = success(send("$ADMIN_PATH?placement=$placement", token = token)) + assertEquals(active.map { it.id }, ids(admin)) + admin.forEach { data -> + assertEquals( + setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate"), + data.fieldNames().asSequence().toSet() + ) + } + assertEquals( + listOf(publicTree(other)), + subBanners(send(recommendationPath(other.placement)), other.placement).toList() + ) + assertEquals(listOf(other.id), ids(success(send("$ADMIN_PATH?placement=${other.placement}", token = token)))) + assertEquals(retained, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + @ParameterizedTest + @CsvSource( + "2026-10-29T23:58:59.999999999, false, false", + "2026-10-29T23:59:00, true, false", + "2026-10-29T23:59:00.123456789, true, false", + "2026-10-29T23:59:00.999999999, true, false", + "2026-10-29T23:59:59, true, true", + "2026-10-29T23:59:59.123456789, true, true", + "2026-10-29T23:59:59.999999999, true, true", + "2026-10-30T00:00:00, false, false", + "2026-10-30T00:00:00.123456789, false, false" + ) + @DisplayName("고정 UTC now는 한 번만 읽고 초 절삭하여 시작·종료·동일 초·날짜 경계를 포함 판정한다") + fun shouldUseOneSecondPrecisionNowAcrossInclusiveBoundaries(now: String, inMinute: Boolean, atInstant: Boolean) { + val start = LocalDateTime.parse("2026-10-29T23:59:00") + val end = LocalDateTime.parse("2026-10-29T23:59:59") + SubBannerPlacement.values().forEach { placement -> + val minute = save(banner(placement, start = start, end = end)) + val instant = save(banner(placement, start = end, end = end)) + time.set(LocalDateTime.parse(now)) + val response = send(recommendationPath(placement)) + evidence("UTC $now $placement", response) + val expected = listOfNotNull(minute.takeIf { inMinute }, instant.takeIf { atInstant }) + assertEquals(expected.map(::publicTree), subBanners(response, placement).toList()) + assertEquals(1, time.calls.get()) + assertEquals(start, reload(requireNotNull(minute.id)).startDate) + assertEquals(end, reload(requireNotNull(minute.id)).endDate) + } + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("공개 목록은 pagination이나 20개 제한 없이 모든 항목을 id 오름차순으로 반환한다") + fun shouldReturnAllVisibleRowsWithoutLimit(placement: SubBannerPlacement) { + val saved = (0 until 25).map { index -> save(banner(placement).copy(imagePath = "sub_banner/$index.png")) } + save(banner(SubBannerPlacement.values().first { it != placement })) + val response = send(recommendationPath(placement)) + evidence("25 untruncated $placement", response) + val data = subBanners(response, placement) + assertEquals(saved.map(::publicTree), data.toList()) + assertEquals(saved.map { requireNotNull(it.id) }.sorted(), ids(data)) + assertEquals(25, data.size()) + assertEquals(1, time.calls.get()) + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("iOS 요청도 Android 전용 행과 모든 저장 링크 원문을 공개 DTO 6필드 그대로 받는다") + fun shouldPreserveAllStoredLinksWithoutServerSideOsFiltering(placement: SubBannerPlacement) { + val androidOnly = save(banner(placement).copy(webUrl = null, androidDeepLink = "sampleapp://Item?id=a%2Fb")) + val allLinks = save( + banner(placement).copy( + webUrl = "HTTPS://Example.TEST/a%2fb/../x?b=2&a=%2F+%20#Frag", + androidDeepLink = "Intent://promo/Item?id=a%2Fb#Intent;scheme=sample;end", + iosDeepLink = "new-custom:Item?value=${"a".repeat(512)}%2f" + ) + ) + val response = send(recommendationPath(placement), userAgent = "iPhone iOS") + evidence("all OS links $placement", response) + assertEquals(listOf(androidOnly, allLinks).map(::publicTree), subBanners(response, placement).toList()) + assertEquals(1, time.calls.get()) + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("추천 경로가 placement를 고정하며 다른 placement query로 바꿀 수 없다") + fun shouldKeepPlacementFixedByRecommendationRoute(placement: SubBannerPlacement) { + val saved = save(banner(placement)) + val other = SubBannerPlacement.values().first { it != placement } + save(banner(other)) + val response = send("${recommendationPath(placement)}?placement=$other") + assertEquals(listOf(publicTree(saved)), subBanners(response, placement).toList()) + assertEquals(1, time.calls.get()) + } + + @Test + @DisplayName("폐기한 앱 GET은 handler가 없고 인증 요청도 404이며 익명 요청은 기존 인증 정책으로 거부한다") + fun shouldRemoveStandaloneHandlerAndReturnNotFoundForAuthenticatedRequest() { + save(banner()) + val before = storedRows() + val response = send("$RETIRED_PATH?placement=HOME_RECOMMENDATION", token = tokenFor(MemberRole.USER)) + evidence("retired authenticated GET", response) + assertEquals(HttpStatus.NOT_FOUND, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertEquals(404, body["status"].asInt()) + assertFalse(body.has("trace")) + assertFalse(body.has("exception")) + assertFalse(requestMappingHandlerMapping.handlerMethods.keys.any { RETIRED_PATH in it.patternValues }) + val anonymous = send("$RETIRED_PATH?placement=HOME_RECOMMENDATION") + evidence("retired anonymous GET", anonymous) + assertEquals(HttpStatus.UNAUTHORIZED, anonymous.statusCode, anonymous.body) + assertEquals(0, time.calls.get()) + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + @Test + @DisplayName("익명은 정확한 GET만 허용하고 HEAD·변경 method·하위·접두사 인접 경로는 401이다") + fun shouldPermitOnlyExactPublicGetAndKeepAdminProtected() { + val saved = save(banner()) + val before = storedRows() + SubBannerPlacement.values().forEach { placement -> + val path = recommendationPath(placement) + val expected = if (placement == saved.placement) listOf(publicTree(saved)) else emptyList() + assertEquals(expected, subBanners(send(path), placement).toList()) + listOf(HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE, HttpMethod.HEAD, HttpMethod.PATCH).forEach { method -> + val response = send(path, method) + evidence("anonymous $method $path", response) + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode, response.body) + } + listOf("$path/", "$path/${saved.id}", "$path/extra", "$path-extra").forEach { neighbor -> + val response = send(neighbor) + evidence("anonymous GET $neighbor", response) + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode, response.body) + } + } + listOf("lives", "debut-creators", "ai-characters").forEach { section -> + val response = send("/api/v2/home/recommendations/$section") + evidence("anonymous home page $section", response) + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode, response.body) + } + listOf(HttpMethod.GET to "$ADMIN_PATH?placement=HOME_RECOMMENDATION", HttpMethod.DELETE to "$ADMIN_PATH/${saved.id}") + .forEach { (method, path) -> + val response = send(path, method) + evidence("anonymous admin $method", response) + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode, response.body) + } + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + @Test + @DisplayName("실제 비관리자 JWT의 관리자 조회·삭제 거부는 기존 HTTP 200 success false를 유지한다") + fun shouldKeepNonAdminReadAndMutationDeniedWithoutSideEffects() { + val saved = save(banner()) + val before = storedRows() + val token = tokenFor(MemberRole.USER) + listOf(HttpMethod.GET to "$ADMIN_PATH?placement=HOME_RECOMMENDATION", HttpMethod.DELETE to "$ADMIN_PATH/${saved.id}") + .forEach { (method, path) -> + val response = send(path, method, token) + evidence("non-admin $method", response) + assertEquals(HttpStatus.OK, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertEquals(setOf("success", "message", "data", "errorProperty"), body.fieldNames().asSequence().toSet()) + assertFalse(body["success"].asBoolean()) + assertEquals("You do not have permission.", body["message"].asText()) + assertTrue(body["data"].isNull) + } + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + private fun send( + path: String, + method: HttpMethod = HttpMethod.GET, + token: String? = null, + userAgent: String = "sub-banner-http-test" + ): ResponseEntity = restTemplate.exchange( + "http://127.0.0.1:$port$path", + method, + HttpEntity( + HttpHeaders().apply { + token?.let { setBearerAuth(it) } + set(HttpHeaders.ACCEPT_LANGUAGE, "en") + set(HttpHeaders.USER_AGENT, userAgent) + accept = listOf(MediaType.APPLICATION_JSON) + } + ), + String::class.java + ) + + private fun success(response: ResponseEntity): JsonNode { + val data = successData(response) + assertTrue(data.isArray) + return data + } + + private fun successData(response: ResponseEntity): JsonNode { + assertEquals(HttpStatus.OK, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertEquals(setOf("success", "message", "data", "errorProperty"), body.fieldNames().asSequence().toSet()) + assertTrue(body["success"].asBoolean(), response.body) + assertTrue(body["message"].isNull) + assertTrue(body["errorProperty"].isNull) + return body["data"] + } + + private fun subBanners(response: ResponseEntity, placement: SubBannerPlacement): JsonNode { + val data = successData(response) + assertTrue(data.isObject, response.body) + assertTrue(data.has("subBanners"), "Recommendation response must include subBanners: ${response.body}") + val legacyBanner = """"banners":[{"imageUrl":"https://cdn.test/legacy.png","eventItem":null, + "creatorId":null,"seriesId":null,"link":"https://example.test/legacy"}]""" + val expected = objectMapper.readTree( + when (placement) { + SubBannerPlacement.HOME_RECOMMENDATION -> + """{ + $legacyBanner,"lives":[],"recentlyActiveCreators":[],"recentDebutCreators":[], + "firstAudioContents":[],"aiCharacters":[],"genreCreators":[],"cheerCreators":[],"popularCommunityPosts":[] + }""" + SubBannerPlacement.CONTENT_RECOMMENDATION -> + """{ + $legacyBanner,"originalSeries":[],"latestAudios":[],"newAndHotAudios":[],"freeAudios":[], + "pointAudios":[],"mostCommentedAudios":[],"recommendedAudios":[] + }""" + } + ) + assertEquals(expected.fieldNames().asSequence().toSet() + "subBanners", data.fieldNames().asSequence().toSet()) + expected.fields().forEach { (field, value) -> assertEquals(value, data[field], "Existing $field must be preserved") } + assertTrue(data["subBanners"].isArray, response.body) + return data["subBanners"] + } + + private fun recommendationPath(placement: SubBannerPlacement): String = when (placement) { + SubBannerPlacement.HOME_RECOMMENDATION -> "/api/v2/home/recommendations" + SubBannerPlacement.CONTENT_RECOMMENDATION -> "/api/v2/audio/recommendations" + } + + private fun legacyBanner(): AudioContentBanner = AudioContentBanner( + thumbnailImage = "legacy.png", + type = AudioContentBannerType.LINK + ).apply { link = "https://example.test/legacy" } + + private fun publicTree(banner: SubBanner): JsonNode = objectMapper.readTree( + objectMapper.writeValueAsString( + mapOf( + "id" to banner.id, + "placement" to banner.placement.name, + "imageUrl" to "https://cdn.test/${banner.imagePath}", + "webUrl" to banner.webUrl, + "androidDeepLink" to banner.androidDeepLink, + "iosDeepLink" to banner.iosDeepLink + ) + ) + ) + + private fun ids(data: JsonNode): List = data.map { it["id"].asLong() } + + private fun banner( + placement: SubBannerPlacement = SubBannerPlacement.HOME_RECOMMENDATION, + start: LocalDateTime? = null, + end: LocalDateTime? = null + ): SubBanner = SubBanner( + placement = placement, + imagePath = "sub_banner/test.png", + webUrl = "https://example.test/promo?x=a%2Fb", + androidDeepLink = null, + iosDeepLink = null, + startDate = start, + endDate = end + ) + + private fun save(banner: SubBanner): SubBanner = requireNotNull( + transactionTemplate.execute { + val saved = repository.save(banner) + entityManager.flush() + entityManager.clear() + repository.findById(requireNotNull(saved.id)) + } + ) + + private fun reload(id: Long): SubBanner = requireNotNull( + transactionTemplate.execute { + entityManager.clear() + repository.findById(id) + } + ) + + private fun storedRows(): List> = jdbcTemplate.queryForList("select * from sub_banner order by id") + + private fun tokenFor(role: MemberRole): String { + val member = memberRepository.saveAndFlush( + Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "test", role = role) + ) + val id = requireNotNull(member.id) + memberIds.add(id) + return tokenProvider.createToken( + UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${role.name}"))), + id + ) + } + + private fun evidence(label: String, response: ResponseEntity) { + println("HTTP EVIDENCE $label ${response.statusCodeValue}: ${response.body}") + } + + class TestTime { + private val value = AtomicReference() + val calls = AtomicInteger() + + fun set(now: LocalDateTime) { + value.set(now) + calls.set(0) + } + + fun now(): LocalDateTime { + calls.incrementAndGet() + return value.get() + } + } + + @TestConfiguration + class TimeConfiguration { + @Bean + fun subBannerTestTime(): TestTime = TestTime() + + @Bean + @Primary + fun fixedTimeSubBannerService( + repository: SubBannerRepository, + s3Uploader: S3Uploader, + objectMapper: ObjectMapper, + time: TestTime + ): SubBannerService = SubBannerService(repository, s3Uploader, objectMapper, "test-bucket", time::now) + } + + companion object { + private const val RETIRED_PATH = "/api/v2/sub-banners" + private const val ADMIN_PATH = "/admin/sub-banners" + } +}