feat(subbanner): 홈과 콘텐츠 추천 응답에 서브 배너를 통합한다

This commit is contained in:
2026-10-29 23:23:20 +09:00
parent a30196e0c9
commit 73aa6da7f0
12 changed files with 587 additions and 5 deletions
@@ -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)
}
}
@@ -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),
@@ -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,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
)
}
}
@@ -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(),
@@ -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)
@@ -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())
@@ -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)
@@ -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"
)
@@ -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())
@@ -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"
}
}