feat(subbanner): 홈과 콘텐츠 추천 응답에 서브 배너를 통합한다
This commit is contained in:
+10
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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<RecommendationBannerResponse>,
|
||||
val subBanners: List<SubBannerResponse>,
|
||||
val originalSeries: List<OriginalSeriesResponse>,
|
||||
val latestAudios: List<AudioCardResponse>,
|
||||
val newAndHotAudios: List<AudioCardResponse>,
|
||||
@@ -18,9 +20,10 @@ data class AudioRecommendationsResponse(
|
||||
val recommendedAudios: List<AudioCardResponse>
|
||||
) {
|
||||
companion object {
|
||||
fun from(recommendations: AudioRecommendations): AudioRecommendationsResponse {
|
||||
fun from(recommendations: AudioRecommendations, subBanners: List<SubBannerResponse>): 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),
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+2
@@ -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<HomeLiveItem>,
|
||||
val banners: List<RecommendationBannerResponse>,
|
||||
val subBanners: List<SubBannerResponse>,
|
||||
val recentlyActiveCreators: List<HomeActiveCreatorItem>,
|
||||
val recentDebutCreators: List<HomeCreatorItem>,
|
||||
val firstAudioContents: List<HomeFirstAudioContentItem>,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
@@ -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(),
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
+3
-1
@@ -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())
|
||||
|
||||
+6
-1
@@ -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)
|
||||
|
||||
+2
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
+2
@@ -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())
|
||||
|
||||
+525
@@ -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<Long>()
|
||||
|
||||
@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<JsonNode>(), 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<String> = restTemplate.exchange(
|
||||
"http://127.0.0.1:$port$path",
|
||||
method,
|
||||
HttpEntity<Any>(
|
||||
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<String>): JsonNode {
|
||||
val data = successData(response)
|
||||
assertTrue(data.isArray)
|
||||
return data
|
||||
}
|
||||
|
||||
private fun successData(response: ResponseEntity<String>): 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<String>, 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<Long> = 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<Map<String, Any>> = 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<String>) {
|
||||
println("HTTP EVIDENCE $label ${response.statusCodeValue}: ${response.body}")
|
||||
}
|
||||
|
||||
class TestTime {
|
||||
private val value = AtomicReference<LocalDateTime>()
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user