fix(subbanner): 언어 설정과 국가별 공개 필터를 추가한다
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
-- 서버 배포 전에 적용한다. KO는 임시 기본 분류이며 JA 소재와 연결 이벤트 언어는 운영 담당자가 수동 확인한다.
|
||||||
|
SET @schema_name := DATABASE();
|
||||||
|
|
||||||
|
SET @lang_column_exists := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = @schema_name
|
||||||
|
AND table_name = 'sub_banner'
|
||||||
|
AND column_name = 'lang'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @add_lang_column_sql := IF(
|
||||||
|
@lang_column_exists = 0,
|
||||||
|
'ALTER TABLE sub_banner ADD COLUMN lang VARCHAR(10) NULL COMMENT ''서브 배너 노출 언어'' AFTER is_active',
|
||||||
|
'SELECT ''sub_banner.lang already exists'' AS message'
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE add_lang_column_stmt FROM @add_lang_column_sql;
|
||||||
|
EXECUTE add_lang_column_stmt;
|
||||||
|
DEALLOCATE PREPARE add_lang_column_stmt;
|
||||||
|
|
||||||
|
UPDATE sub_banner
|
||||||
|
SET lang = 'KO'
|
||||||
|
WHERE lang IS NULL;
|
||||||
|
|
||||||
|
SET @lang_column_nullable := (
|
||||||
|
SELECT IS_NULLABLE
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = @schema_name
|
||||||
|
AND table_name = 'sub_banner'
|
||||||
|
AND column_name = 'lang'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @alter_lang_column_sql := IF(
|
||||||
|
@lang_column_nullable = 'YES',
|
||||||
|
'ALTER TABLE sub_banner MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''서브 배너 노출 언어 (KO 기본, EN/JA 등록 가능)''',
|
||||||
|
'SELECT ''sub_banner.lang already normalized'' AS message'
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql;
|
||||||
|
EXECUTE alter_lang_column_stmt;
|
||||||
|
DEALLOCATE PREPARE alter_lang_column_stmt;
|
||||||
@@ -8,6 +8,7 @@ CREATE TABLE sub_banner (
|
|||||||
start_date TIMESTAMP NULL DEFAULT NULL COMMENT '노출 시작 시각 (UTC 초 단위, NULL이면 시작 제한 없음)',
|
start_date TIMESTAMP NULL DEFAULT NULL COMMENT '노출 시작 시각 (UTC 초 단위, NULL이면 시작 제한 없음)',
|
||||||
end_date TIMESTAMP NULL DEFAULT NULL COMMENT '노출 종료 시각 (UTC 초 단위, NULL이면 종료 제한 없음)',
|
end_date TIMESTAMP NULL DEFAULT NULL COMMENT '노출 종료 시각 (UTC 초 단위, NULL이면 종료 제한 없음)',
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부 (false이면 삭제)',
|
is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부 (false이면 삭제)',
|
||||||
|
lang VARCHAR(10) NOT NULL DEFAULT 'KO' COMMENT '서브 배너 노출 언어 (KO 기본, EN/JA 등록 가능)',
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ class AudioRecommendationFacade(
|
|||||||
) {
|
) {
|
||||||
fun getRecommendations(member: Member?, lang: Lang): AudioRecommendationsResponse {
|
fun getRecommendations(member: Member?, lang: Lang): AudioRecommendationsResponse {
|
||||||
val result = queryService.getRecommendations(member, lang)
|
val result = queryService.getRecommendations(member, lang)
|
||||||
val subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.CONTENT_RECOMMENDATION)
|
val subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.CONTENT_RECOMMENDATION, member)
|
||||||
.map { SubBannerResponse.from(it, cloudFrontHost) }
|
.map { SubBannerResponse.from(it, cloudFrontHost) }
|
||||||
return AudioRecommendationsResponse.from(result, subBanners)
|
return AudioRecommendationsResponse.from(result, subBanners)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@ class HomeRecommendationFacade(
|
|||||||
lang = bannerLang
|
lang = bannerLang
|
||||||
)
|
)
|
||||||
.map { RecommendationBannerResponse.from(it.toBanner()) },
|
.map { RecommendationBannerResponse.from(it.toBanner()) },
|
||||||
subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION)
|
subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION, member)
|
||||||
.map { SubBannerResponse.from(it, cloudFrontHost) },
|
.map { SubBannerResponse.from(it, cloudFrontHost) },
|
||||||
recentlyActiveCreators = queryService.findRecentlyActiveCreators(
|
recentlyActiveCreators = queryService.findRecentlyActiveCreators(
|
||||||
HOME_ACTIVE_CREATOR_LIMIT,
|
HOME_ACTIVE_CREATOR_LIMIT,
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence
|
package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence
|
||||||
|
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence.QSubBannerEntity.subBannerEntity
|
import kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence.QSubBannerEntity.subBannerEntity
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
|
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.domain.SubBannerPlacement
|
||||||
@@ -25,12 +26,13 @@ class DefaultSubBannerRepository(
|
|||||||
.fetch()
|
.fetch()
|
||||||
.map { it.toDomain() }
|
.map { it.toDomain() }
|
||||||
|
|
||||||
override fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List<SubBanner> {
|
override fun findVisibleByPlacement(placement: SubBannerPlacement, lang: Lang, now: LocalDateTime): List<SubBanner> {
|
||||||
val visibleAt = now.truncatedTo(ChronoUnit.SECONDS)
|
val visibleAt = now.truncatedTo(ChronoUnit.SECONDS)
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.selectFrom(subBannerEntity)
|
.selectFrom(subBannerEntity)
|
||||||
.where(
|
.where(
|
||||||
subBannerEntity.placement.eq(placement),
|
subBannerEntity.placement.eq(placement),
|
||||||
|
subBannerEntity.lang.eq(lang),
|
||||||
subBannerEntity.isActive.isTrue,
|
subBannerEntity.isActive.isTrue,
|
||||||
subBannerEntity.startDate.isNull.or(subBannerEntity.startDate.loe(visibleAt)),
|
subBannerEntity.startDate.isNull.or(subBannerEntity.startDate.loe(visibleAt)),
|
||||||
subBannerEntity.endDate.isNull.or(subBannerEntity.endDate.goe(visibleAt))
|
subBannerEntity.endDate.isNull.or(subBannerEntity.endDate.goe(visibleAt))
|
||||||
@@ -51,7 +53,8 @@ class DefaultSubBannerRepository(
|
|||||||
subBanner.androidDeepLink,
|
subBanner.androidDeepLink,
|
||||||
subBanner.iosDeepLink,
|
subBanner.iosDeepLink,
|
||||||
startDate,
|
startDate,
|
||||||
endDate
|
endDate,
|
||||||
|
lang = subBanner.lang
|
||||||
).also { entityManager.persist(it) }
|
).also { entityManager.persist(it) }
|
||||||
} else {
|
} else {
|
||||||
entityManager.find(SubBannerEntity::class.java, subBanner.id).apply {
|
entityManager.find(SubBannerEntity::class.java, subBanner.id).apply {
|
||||||
@@ -62,6 +65,7 @@ class DefaultSubBannerRepository(
|
|||||||
iosDeepLink = subBanner.iosDeepLink
|
iosDeepLink = subBanner.iosDeepLink
|
||||||
this.startDate = startDate
|
this.startDate = startDate
|
||||||
this.endDate = endDate
|
this.endDate = endDate
|
||||||
|
lang = subBanner.lang
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return entity.toDomain()
|
return entity.toDomain()
|
||||||
|
|||||||
+8
-2
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence
|
package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
|
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.domain.SubBannerPlacement
|
||||||
import org.hibernate.annotations.DynamicUpdate
|
import org.hibernate.annotations.DynamicUpdate
|
||||||
@@ -39,7 +40,12 @@ class SubBannerEntity(
|
|||||||
var endDate: LocalDateTime?,
|
var endDate: LocalDateTime?,
|
||||||
|
|
||||||
@Column(name = "is_active", nullable = false)
|
@Column(name = "is_active", nullable = false)
|
||||||
var isActive: Boolean = true
|
var isActive: Boolean = true,
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "lang", nullable = false, length = 10)
|
||||||
|
var lang: Lang = Lang.KO
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
fun toDomain(): SubBanner = SubBanner(id, placement, imagePath, webUrl, androidDeepLink, iosDeepLink, startDate, endDate)
|
fun toDomain(): SubBanner =
|
||||||
|
SubBanner(id, placement, imagePath, webUrl, androidDeepLink, iosDeepLink, startDate, endDate, lang)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-4
@@ -6,6 +6,9 @@ import com.fasterxml.jackson.databind.JsonNode
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import kr.co.vividnext.sodalive.utils.validateImage
|
import kr.co.vividnext.sodalive.utils.validateImage
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
|
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
|
||||||
@@ -33,6 +36,7 @@ class SubBannerService(
|
|||||||
private val repository: SubBannerRepository,
|
private val repository: SubBannerRepository,
|
||||||
private val s3Uploader: S3Uploader,
|
private val s3Uploader: S3Uploader,
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||||
@Value("\${cloud.aws.s3.bucket}") private val bucket: String,
|
@Value("\${cloud.aws.s3.bucket}") private val bucket: String,
|
||||||
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
|
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
|
||||||
) {
|
) {
|
||||||
@@ -40,9 +44,10 @@ class SubBannerService(
|
|||||||
fun findAllByPlacement(placement: SubBannerPlacement): List<SubBanner> = repository.findAllByPlacement(placement)
|
fun findAllByPlacement(placement: SubBannerPlacement): List<SubBanner> = repository.findAllByPlacement(placement)
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findVisibleByPlacement(placement: SubBannerPlacement): List<SubBanner> {
|
fun findVisibleByPlacement(placement: SubBannerPlacement, member: Member?): List<SubBanner> {
|
||||||
val now = nowProvider().truncatedTo(ChronoUnit.SECONDS)
|
val now = nowProvider().truncatedTo(ChronoUnit.SECONDS)
|
||||||
return repository.findVisibleByPlacement(placement, now)
|
val lang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
||||||
|
return repository.findVisibleByPlacement(placement, lang, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -61,7 +66,8 @@ class SubBannerService(
|
|||||||
androidDeepLink = input.androidDeepLink,
|
androidDeepLink = input.androidDeepLink,
|
||||||
iosDeepLink = input.iosDeepLink,
|
iosDeepLink = input.iosDeepLink,
|
||||||
startDate = startDate,
|
startDate = startDate,
|
||||||
endDate = endDate
|
endDate = endDate,
|
||||||
|
lang = input.lang
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -83,7 +89,8 @@ class SubBannerService(
|
|||||||
androidDeepLink = input.androidDeepLink,
|
androidDeepLink = input.androidDeepLink,
|
||||||
iosDeepLink = input.iosDeepLink,
|
iosDeepLink = input.iosDeepLink,
|
||||||
startDate = startDate,
|
startDate = startDate,
|
||||||
endDate = endDate
|
endDate = endDate,
|
||||||
|
lang = if (fields.has("lang")) input.lang else existing.lang
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -118,6 +125,14 @@ class SubBannerService(
|
|||||||
val value = fields[field]
|
val value = fields[field]
|
||||||
if (value != null && !value.isNull && !value.isTextual) invalidRequest()
|
if (value != null && !value.isNull && !value.isTextual) invalidRequest()
|
||||||
}
|
}
|
||||||
|
fields["lang"]?.let { value ->
|
||||||
|
if (!value.isTextual) invalidRequest()
|
||||||
|
try {
|
||||||
|
Lang.fromCode(value.textValue())
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
invalidRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.subbanner.domain
|
package kr.co.vividnext.sodalive.v2.subbanner.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
data class SubBanner(
|
data class SubBanner(
|
||||||
@@ -10,7 +11,8 @@ data class SubBanner(
|
|||||||
val androidDeepLink: String?,
|
val androidDeepLink: String?,
|
||||||
val iosDeepLink: String?,
|
val iosDeepLink: String?,
|
||||||
val startDate: LocalDateTime?,
|
val startDate: LocalDateTime?,
|
||||||
val endDate: LocalDateTime?
|
val endDate: LocalDateTime?,
|
||||||
|
val lang: Lang = Lang.KO
|
||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
require(!webUrl.isNullOrBlank() || !androidDeepLink.isNullOrBlank() || !iosDeepLink.isNullOrBlank()) {
|
require(!webUrl.isNullOrBlank() || !androidDeepLink.isNullOrBlank() || !iosDeepLink.isNullOrBlank()) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.subbanner.dto
|
package kr.co.vividnext.sodalive.v2.subbanner.dto
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
|
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.domain.SubBannerPlacement
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -15,7 +16,8 @@ data class AdminSubBannerResponse(
|
|||||||
val androidDeepLink: String?,
|
val androidDeepLink: String?,
|
||||||
val iosDeepLink: String?,
|
val iosDeepLink: String?,
|
||||||
val startDate: String?,
|
val startDate: String?,
|
||||||
val endDate: String?
|
val endDate: String?,
|
||||||
|
val lang: Lang
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(banner: SubBanner, cloudFrontHost: String): AdminSubBannerResponse = AdminSubBannerResponse(
|
fun from(banner: SubBanner, cloudFrontHost: String): AdminSubBannerResponse = AdminSubBannerResponse(
|
||||||
@@ -26,7 +28,8 @@ data class AdminSubBannerResponse(
|
|||||||
androidDeepLink = banner.androidDeepLink,
|
androidDeepLink = banner.androidDeepLink,
|
||||||
iosDeepLink = banner.iosDeepLink,
|
iosDeepLink = banner.iosDeepLink,
|
||||||
startDate = formatDate(banner.startDate),
|
startDate = formatDate(banner.startDate),
|
||||||
endDate = formatDate(banner.endDate)
|
endDate = formatDate(banner.endDate),
|
||||||
|
lang = banner.lang
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun formatDate(value: LocalDateTime?): String? = value?.atZone(ZoneOffset.UTC)
|
private fun formatDate(value: LocalDateTime?): String? = value?.atZone(ZoneOffset.UTC)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.subbanner.dto
|
package kr.co.vividnext.sodalive.v2.subbanner.dto
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
|
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
|
||||||
|
|
||||||
data class SubBannerRequest(
|
data class SubBannerRequest(
|
||||||
@@ -8,5 +9,6 @@ data class SubBannerRequest(
|
|||||||
val androidDeepLink: String?,
|
val androidDeepLink: String?,
|
||||||
val iosDeepLink: String?,
|
val iosDeepLink: String?,
|
||||||
val startDate: String? = null,
|
val startDate: String? = null,
|
||||||
val endDate: String? = null
|
val endDate: String? = null,
|
||||||
|
val lang: Lang = Lang.KO
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.subbanner.port.out
|
package kr.co.vividnext.sodalive.v2.subbanner.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
|
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.domain.SubBannerPlacement
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -7,7 +8,7 @@ import java.time.LocalDateTime
|
|||||||
interface SubBannerRepository {
|
interface SubBannerRepository {
|
||||||
fun findById(id: Long): SubBanner?
|
fun findById(id: Long): SubBanner?
|
||||||
fun findAllByPlacement(placement: SubBannerPlacement): List<SubBanner>
|
fun findAllByPlacement(placement: SubBannerPlacement): List<SubBanner>
|
||||||
fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List<SubBanner>
|
fun findVisibleByPlacement(placement: SubBannerPlacement, lang: Lang, now: LocalDateTime): List<SubBanner>
|
||||||
fun save(subBanner: SubBanner): SubBanner
|
fun save(subBanner: SubBanner): SubBanner
|
||||||
fun deleteById(id: Long)
|
fun deleteById(id: Long)
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-1
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.content.recommendation.application
|
|||||||
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService
|
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
||||||
@@ -9,6 +10,8 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommenda
|
|||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
|
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.content.recommendation.domain.OriginalSeries
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
|
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 org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -17,7 +20,35 @@ import org.mockito.Mockito
|
|||||||
class AudioRecommendationFacadeTest {
|
class AudioRecommendationFacadeTest {
|
||||||
private val objectMapper = jacksonObjectMapper()
|
private val objectMapper = jacksonObjectMapper()
|
||||||
private val queryService = Mockito.mock(AudioRecommendationQueryService::class.java)
|
private val queryService = Mockito.mock(AudioRecommendationQueryService::class.java)
|
||||||
private val facade = AudioRecommendationFacade(queryService, Mockito.mock(SubBannerService::class.java), "https://cdn.test")
|
private val subBannerService = Mockito.mock(SubBannerService::class.java)
|
||||||
|
private val facade = AudioRecommendationFacade(queryService, subBannerService, "https://cdn.test")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("콘텐츠 추천은 언어 context 대신 nullable 회원을 서브 배너 조회에 전달하고 공개 결과를 조립한다")
|
||||||
|
fun shouldComposeSubBannersUsingTheViewerRatherThanContextLanguage() {
|
||||||
|
val member = Member(password = "password", nickname = "viewer").apply { id = 900001L }
|
||||||
|
listOf(null, member).forEach { viewer ->
|
||||||
|
val banner = SubBanner(
|
||||||
|
id = 123L,
|
||||||
|
placement = SubBannerPlacement.CONTENT_RECOMMENDATION,
|
||||||
|
imagePath = "sub_banner/ja.png",
|
||||||
|
webUrl = "https://example.test/promo",
|
||||||
|
androidDeepLink = null,
|
||||||
|
iosDeepLink = null,
|
||||||
|
startDate = null,
|
||||||
|
endDate = null,
|
||||||
|
lang = Lang.JA
|
||||||
|
)
|
||||||
|
Mockito.doReturn(domain()).`when`(queryService).getRecommendations(viewer, Lang.EN)
|
||||||
|
Mockito.doReturn(listOf(banner)).`when`(subBannerService)
|
||||||
|
.findVisibleByPlacement(SubBannerPlacement.CONTENT_RECOMMENDATION, viewer)
|
||||||
|
|
||||||
|
val result = facade.getRecommendations(viewer, Lang.EN)
|
||||||
|
|
||||||
|
assertEquals(listOf(123L), result.subBanners.map { it.id })
|
||||||
|
assertEquals("https://cdn.test/sub_banner/ja.png", result.subBanners.single().imageUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("facade는 도메인 추천 결과를 모든 공개 응답 필드로 변환한다")
|
@DisplayName("facade는 도메인 추천 결과를 모든 공개 응답 필드로 변환한다")
|
||||||
|
|||||||
+30
-1
@@ -23,6 +23,8 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreator
|
|||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
|
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.recommendation.port.out.RecommendationSnapshotRecord
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
|
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 org.junit.jupiter.api.Assertions.assertAll
|
import org.junit.jupiter.api.Assertions.assertAll
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
@@ -44,13 +46,40 @@ class HomeRecommendationFacadeTest {
|
|||||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||||
)
|
)
|
||||||
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||||
|
private val subBannerService = Mockito.mock(SubBannerService::class.java)
|
||||||
private val facade = HomeRecommendationFacade(
|
private val facade = HomeRecommendationFacade(
|
||||||
queryService,
|
queryService,
|
||||||
preferenceService,
|
preferenceService,
|
||||||
Mockito.mock(SubBannerService::class.java),
|
subBannerService,
|
||||||
"https://cdn.test"
|
"https://cdn.test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈은 언어 context 대신 nullable 회원을 서브 배너 조회에 전달하고 공개 결과를 조립한다")
|
||||||
|
fun shouldComposeSubBannersUsingTheViewerRatherThanContextLanguage() {
|
||||||
|
val member = Member(password = "password", nickname = "viewer").apply { id = 900001L }
|
||||||
|
listOf(null, member).forEach { viewer ->
|
||||||
|
val banner = SubBanner(
|
||||||
|
id = 123L,
|
||||||
|
placement = SubBannerPlacement.HOME_RECOMMENDATION,
|
||||||
|
imagePath = "sub_banner/ja.png",
|
||||||
|
webUrl = "https://example.test/promo",
|
||||||
|
androidDeepLink = null,
|
||||||
|
iosDeepLink = null,
|
||||||
|
startDate = null,
|
||||||
|
endDate = null,
|
||||||
|
lang = Lang.JA
|
||||||
|
)
|
||||||
|
Mockito.doReturn(listOf(banner)).`when`(subBannerService)
|
||||||
|
.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION, viewer)
|
||||||
|
|
||||||
|
val result = facade.getHomeRecommendations(viewer, Lang.EN)
|
||||||
|
|
||||||
|
assertEquals(listOf(123L), result.subBanners.map { it.id })
|
||||||
|
assertEquals("https://cdn.test/sub_banner/ja.png", result.subBanners.single().imageUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("홈 추천은 AI 스냅샷과 상세 및 무작위 보충이나 fallback 조회 없이 빈 AI 목록을 반환한다")
|
@DisplayName("홈 추천은 AI 스냅샷과 상세 및 무작위 보충이나 fallback 조회 없이 빈 AI 목록을 반환한다")
|
||||||
fun shouldReturnEmptyAiCharactersWithoutQueryingAiRecommendations() {
|
fun shouldReturnEmptyAiCharactersWithoutQueryingAiRecommendations() {
|
||||||
|
|||||||
+58
-2
@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.member.MemberRepository
|
|||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
||||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence.DefaultSubBannerRepository
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
|
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository
|
import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
@@ -27,6 +28,7 @@ import org.mockito.Mockito
|
|||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.context.SpringBootTest
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.boot.test.mock.mockito.SpyBean
|
||||||
import org.springframework.boot.test.web.client.TestRestTemplate
|
import org.springframework.boot.test.web.client.TestRestTemplate
|
||||||
import org.springframework.boot.test.web.server.LocalServerPort
|
import org.springframework.boot.test.web.server.LocalServerPort
|
||||||
import org.springframework.core.io.ByteArrayResource
|
import org.springframework.core.io.ByteArrayResource
|
||||||
@@ -78,6 +80,9 @@ class AdminSubBannerControllerIntegrationTest @Autowired constructor(
|
|||||||
@MockBean
|
@MockBean
|
||||||
private lateinit var s3Uploader: S3Uploader
|
private lateinit var s3Uploader: S3Uploader
|
||||||
|
|
||||||
|
@SpyBean
|
||||||
|
private lateinit var writeRepository: DefaultSubBannerRepository
|
||||||
|
|
||||||
private lateinit var adminToken: String
|
private lateinit var adminToken: String
|
||||||
private val memberIds = mutableListOf<Long>()
|
private val memberIds = mutableListOf<Long>()
|
||||||
|
|
||||||
@@ -106,7 +111,7 @@ class AdminSubBannerControllerIntegrationTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("실제 JWT HTTP CRUD는 관리자 8필드·삭제 null을 유지하며 소프트 삭제 후 수정·재삭제를 거부한다")
|
@DisplayName("실제 JWT HTTP CRUD는 관리자 9필드·삭제 null을 유지하며 소프트 삭제 후 수정·재삭제를 거부한다")
|
||||||
fun shouldCreateListUpdateAndSoftDeleteWithExactAdminResponse() {
|
fun shouldCreateListUpdateAndSoftDeleteWithExactAdminResponse() {
|
||||||
val createdResponse = send(HttpMethod.POST)
|
val createdResponse = send(HttpMethod.POST)
|
||||||
evidence("POST", createdResponse)
|
evidence("POST", createdResponse)
|
||||||
@@ -163,6 +168,57 @@ class AdminSubBannerControllerIntegrationTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("언어 생략 생성은 KO이고 JA·EN 수정 뒤 생략은 보존하며 관리자 목록은 모든 언어다")
|
||||||
|
fun shouldDefaultCreateLanguageAndPreserveOmittedUpdateAcrossAllAdminLanguages() {
|
||||||
|
val defaultResponse = send(HttpMethod.POST)
|
||||||
|
evidence("language omitted POST", defaultResponse)
|
||||||
|
val default = success(defaultResponse)
|
||||||
|
assertEquals("KO", default.path("lang").asText())
|
||||||
|
val created = listOf(" ja ", "en").map { lang ->
|
||||||
|
val response = send(HttpMethod.POST, fields = baseFields() + ("lang" to lang))
|
||||||
|
evidence("language $lang POST", response)
|
||||||
|
success(response)
|
||||||
|
}
|
||||||
|
assertEquals(listOf("JA", "EN"), created.map { it.path("lang").asText() })
|
||||||
|
created.forEach { original ->
|
||||||
|
val id = original["id"].asLong()
|
||||||
|
val omitted = send(HttpMethod.PUT, "/$id", image = null)
|
||||||
|
evidence("language omitted PUT", omitted)
|
||||||
|
assertEquals(original, success(omitted))
|
||||||
|
assertEquals(original["lang"], objectMapper.valueToTree<JsonNode>(reload(id))["lang"])
|
||||||
|
}
|
||||||
|
val id = default["id"].asLong()
|
||||||
|
listOf("JA", "EN", "KO").forEach { lang ->
|
||||||
|
val updated = send(HttpMethod.PUT, "/$id", baseFields() + ("lang" to lang), image = null)
|
||||||
|
evidence("language $lang PUT", updated)
|
||||||
|
assertEquals(lang, success(updated).path("lang").asText())
|
||||||
|
assertEquals(lang, objectMapper.valueToTree<JsonNode>(reload(id)).path("lang").asText())
|
||||||
|
}
|
||||||
|
val listing = send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")
|
||||||
|
evidence("language all admin GET", listing)
|
||||||
|
assertEquals(listOf(default) + created, success(listing).toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["null", "0", "true", "[]", "{}", "\"\"", "\" \"", "\"FR\""])
|
||||||
|
@DisplayName("명시적 null·비문자열·blank·알 수 없는 언어는 POST·PUT 업로드·저장 호출 전 400이다")
|
||||||
|
fun shouldRejectInvalidLanguageBeforeUploadOrWrite(raw: String) {
|
||||||
|
val id = createScheduled()
|
||||||
|
val existing = reload(id)
|
||||||
|
val before = storedRows()
|
||||||
|
Mockito.clearInvocations(s3Uploader, writeRepository)
|
||||||
|
val fields = baseFields() + ("lang" to objectMapper.readValue(raw, Any::class.java))
|
||||||
|
listOf(HttpMethod.POST to "", HttpMethod.PUT to "/$id").forEach { (method, suffix) ->
|
||||||
|
val response = send(method, suffix, fields)
|
||||||
|
evidence("invalid language $raw $method", response)
|
||||||
|
assertStatus(response, HttpStatus.BAD_REQUEST)
|
||||||
|
assertEquals(before, storedRows())
|
||||||
|
Mockito.verifyNoInteractions(s3Uploader)
|
||||||
|
Mockito.verify(writeRepository, Mockito.never()).save(Mockito.any(SubBanner::class.java) ?: existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(ints = [0, 1, 2, 3, 4, 5, 6, 7])
|
@ValueSource(ints = [0, 1, 2, 3, 4, 5, 6, 7])
|
||||||
@DisplayName("주소 8개 조합 중 모두 빈 조합만 거부하고 나머지는 생성·수정한다")
|
@DisplayName("주소 8개 조합 중 모두 빈 조합만 거부하고 나머지는 생성·수정한다")
|
||||||
@@ -519,7 +575,7 @@ class AdminSubBannerControllerIntegrationTest @Autowired constructor(
|
|||||||
|
|
||||||
private fun assertAdminFields(data: JsonNode) {
|
private fun assertAdminFields(data: JsonNode) {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate"),
|
setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate", "lang"),
|
||||||
data.fieldNames().asSequence().toSet()
|
data.fieldNames().asSequence().toSet()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-5
@@ -6,10 +6,12 @@ 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.AudioContentBanner
|
||||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
|
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
||||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
|
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
|
||||||
@@ -86,6 +88,29 @@ class RecommendationSubBannerIntegrationTest @Autowired constructor(
|
|||||||
|
|
||||||
private val memberIds = mutableListOf<Long>()
|
private val memberIds = mutableListOf<Long>()
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(SubBannerPlacement::class)
|
||||||
|
@DisplayName("두 공개 추천은 JP·KR·US·국가 누락·강제 매핑에 맞는 언어만 공개 6필드로 반환한다")
|
||||||
|
fun shouldFilterPublicSubBannersByCountryLanguage(placement: SubBannerPlacement) {
|
||||||
|
val korean = save(banner(placement))
|
||||||
|
val response = send(recommendationPath(placement), country = "JP")
|
||||||
|
evidence("language JP $placement", response)
|
||||||
|
assertEquals(emptyList<JsonNode>(), successData(response)["subBanners"].toList())
|
||||||
|
val japanese = save(banner(placement).copy(lang = Lang.JA))
|
||||||
|
save(banner(placement).copy(lang = Lang.EN))
|
||||||
|
listOf("JP" to japanese, "KR" to korean, "US" to korean, null to korean).forEach { (country, expected) ->
|
||||||
|
val result = send(recommendationPath(placement), country = country)
|
||||||
|
evidence("language country=$country $placement", result)
|
||||||
|
assertEquals(listOf(publicTree(expected)), successData(result)["subBanners"].toList())
|
||||||
|
}
|
||||||
|
listOf(29721L to japanese, 16L to korean).forEach { (memberId, expected) ->
|
||||||
|
val country = if (memberId == 29721L) "KR" else "JP"
|
||||||
|
val result = send(recommendationPath(placement), token = tokenFor(MemberRole.USER, memberId), country = country)
|
||||||
|
evidence("language forced=$memberId country=$country $placement", result)
|
||||||
|
assertEquals(listOf(publicTree(expected)), successData(result)["subBanners"].toList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
jdbcTemplate.update("delete from sub_banner")
|
jdbcTemplate.update("delete from sub_banner")
|
||||||
@@ -107,6 +132,7 @@ class RecommendationSubBannerIntegrationTest @Autowired constructor(
|
|||||||
jdbcTemplate.update("delete from content_banner")
|
jdbcTemplate.update("delete from content_banner")
|
||||||
jdbcTemplate.update("delete from audio_content_main_tab")
|
jdbcTemplate.update("delete from audio_content_main_tab")
|
||||||
tokenRepository.deleteAll()
|
tokenRepository.deleteAll()
|
||||||
|
memberIds.forEach { jdbcTemplate.update("delete from member_content_preference where member_id = ?", it) }
|
||||||
memberRepository.deleteAllById(memberIds)
|
memberRepository.deleteAllById(memberIds)
|
||||||
memberIds.clear()
|
memberIds.clear()
|
||||||
}
|
}
|
||||||
@@ -189,7 +215,7 @@ class RecommendationSubBannerIntegrationTest @Autowired constructor(
|
|||||||
assertEquals(active.map { it.id }, ids(admin))
|
assertEquals(active.map { it.id }, ids(admin))
|
||||||
admin.forEach { data ->
|
admin.forEach { data ->
|
||||||
assertEquals(
|
assertEquals(
|
||||||
setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate"),
|
setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate", "lang"),
|
||||||
data.fieldNames().asSequence().toSet()
|
data.fieldNames().asSequence().toSet()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -358,13 +384,15 @@ class RecommendationSubBannerIntegrationTest @Autowired constructor(
|
|||||||
path: String,
|
path: String,
|
||||||
method: HttpMethod = HttpMethod.GET,
|
method: HttpMethod = HttpMethod.GET,
|
||||||
token: String? = null,
|
token: String? = null,
|
||||||
userAgent: String = "sub-banner-http-test"
|
userAgent: String = "sub-banner-http-test",
|
||||||
|
country: String? = null
|
||||||
): ResponseEntity<String> = restTemplate.exchange(
|
): ResponseEntity<String> = restTemplate.exchange(
|
||||||
"http://127.0.0.1:$port$path",
|
"http://127.0.0.1:$port$path",
|
||||||
method,
|
method,
|
||||||
HttpEntity<Any>(
|
HttpEntity<Any>(
|
||||||
HttpHeaders().apply {
|
HttpHeaders().apply {
|
||||||
token?.let { setBearerAuth(it) }
|
token?.let { setBearerAuth(it) }
|
||||||
|
country?.let { set("CloudFront-Viewer-Country", it) }
|
||||||
set(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
set(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
set(HttpHeaders.USER_AGENT, userAgent)
|
set(HttpHeaders.USER_AGENT, userAgent)
|
||||||
accept = listOf(MediaType.APPLICATION_JSON)
|
accept = listOf(MediaType.APPLICATION_JSON)
|
||||||
@@ -472,11 +500,12 @@ class RecommendationSubBannerIntegrationTest @Autowired constructor(
|
|||||||
|
|
||||||
private fun storedRows(): List<Map<String, Any>> = jdbcTemplate.queryForList("select * from sub_banner order by id")
|
private fun storedRows(): List<Map<String, Any>> = jdbcTemplate.queryForList("select * from sub_banner order by id")
|
||||||
|
|
||||||
private fun tokenFor(role: MemberRole): String {
|
private fun tokenFor(role: MemberRole, forcedId: Long? = null): String {
|
||||||
val member = memberRepository.saveAndFlush(
|
val member = memberRepository.saveAndFlush(
|
||||||
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "test", role = role)
|
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "test", role = role)
|
||||||
)
|
)
|
||||||
val id = requireNotNull(member.id)
|
val id = forcedId ?: requireNotNull(member.id)
|
||||||
|
if (forcedId != null) jdbcTemplate.update("update member set id = ? where id = ?", forcedId, member.id)
|
||||||
memberIds.add(id)
|
memberIds.add(id)
|
||||||
return tokenProvider.createToken(
|
return tokenProvider.createToken(
|
||||||
UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${role.name}"))),
|
UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${role.name}"))),
|
||||||
@@ -514,8 +543,16 @@ class RecommendationSubBannerIntegrationTest @Autowired constructor(
|
|||||||
repository: SubBannerRepository,
|
repository: SubBannerRepository,
|
||||||
s3Uploader: S3Uploader,
|
s3Uploader: S3Uploader,
|
||||||
objectMapper: ObjectMapper,
|
objectMapper: ObjectMapper,
|
||||||
|
memberContentPreferenceService: MemberContentPreferenceService,
|
||||||
time: TestTime
|
time: TestTime
|
||||||
): SubBannerService = SubBannerService(repository, s3Uploader, objectMapper, "test-bucket", time::now)
|
): SubBannerService = SubBannerService(
|
||||||
|
repository,
|
||||||
|
s3Uploader,
|
||||||
|
objectMapper,
|
||||||
|
memberContentPreferenceService,
|
||||||
|
"test-bucket",
|
||||||
|
time::now
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+56
-8
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence
|
|||||||
|
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
|
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.domain.SubBannerPlacement
|
||||||
import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository
|
import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository
|
||||||
@@ -50,6 +51,44 @@ class SubBannerPersistenceTest @Autowired constructor(
|
|||||||
jdbcTemplate.execute("alter table sub_banner alter column end_date timestamp(0)")
|
jdbcTemplate.execute("alter table sub_banner alter column end_date timestamp(0)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(Lang::class)
|
||||||
|
@DisplayName("KO 기본값과 모든 언어는 문자열로 왕복하고 공개 조회만 언어·기간·활성을 함께 제한한다")
|
||||||
|
fun shouldPersistAndUpdateLanguageWhileFilteringOnlyPublicRows(lang: Lang) {
|
||||||
|
assertEquals(Lang.KO, banner().lang)
|
||||||
|
val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59)
|
||||||
|
val placement = SubBannerPlacement.HOME_RECOMMENDATION
|
||||||
|
val visible = Lang.values().map { repository.save(banner().copy(lang = it)) }
|
||||||
|
val future = repository.save(banner().copy(lang = lang, startDate = now.plusSeconds(1)))
|
||||||
|
val expired = repository.save(banner().copy(lang = lang, endDate = now.minusSeconds(1)))
|
||||||
|
val deleted = repository.save(banner().copy(lang = lang))
|
||||||
|
repository.deleteById(requireNotNull(deleted.id))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
visible.forEach { expected ->
|
||||||
|
assertEquals(expected, repository.findById(requireNotNull(expected.id)))
|
||||||
|
assertEquals(
|
||||||
|
expected.lang.name,
|
||||||
|
entityManager.createNativeQuery("select lang from sub_banner where id = :id")
|
||||||
|
.setParameter("id", expected.id).singleResult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(visible + future + expired, repository.findAllByPlacement(placement))
|
||||||
|
val selected = visible.single { it.lang == lang }
|
||||||
|
assertEquals(listOf(selected), repository.findVisibleByPlacement(placement, lang, now))
|
||||||
|
val changed = selected.copy(lang = Lang.values().first { it != lang })
|
||||||
|
assertEquals(changed, repository.save(changed))
|
||||||
|
flushAndClear()
|
||||||
|
assertEquals(changed, repository.findById(requireNotNull(changed.id)))
|
||||||
|
assertEquals(emptyList<SubBanner>(), repository.findVisibleByPlacement(placement, lang, now))
|
||||||
|
val columns = jdbcTemplate.queryForMap(
|
||||||
|
"select IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH from INFORMATION_SCHEMA.COLUMNS " +
|
||||||
|
"where TABLE_NAME = 'sub_banner' and COLUMN_NAME = 'lang'"
|
||||||
|
)
|
||||||
|
assertEquals("NO", columns["IS_NULLABLE"])
|
||||||
|
assertEquals(10L, (columns["CHARACTER_MAXIMUM_LENGTH"] as Number).toLong())
|
||||||
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(ints = [0, 123456789, 999999999])
|
@ValueSource(ints = [0, 123456789, 999999999])
|
||||||
@DisplayName("nullable UTC 기간은 저장 결과와 DB 재조회 모두 초 정밀도를 정확히 보존한다")
|
@DisplayName("nullable UTC 기간은 저장 결과와 DB 재조회 모두 초 정밀도를 정확히 보존한다")
|
||||||
@@ -102,7 +141,7 @@ class SubBannerPersistenceTest @Autowired constructor(
|
|||||||
fun shouldReturnAllPlacementRowsInIdOrderWithoutLimit(placement: SubBannerPlacement) {
|
fun shouldReturnAllPlacementRowsInIdOrderWithoutLimit(placement: SubBannerPlacement) {
|
||||||
val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59)
|
val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59)
|
||||||
assertEquals(emptyList<SubBanner>(), repository.findAllByPlacement(placement))
|
assertEquals(emptyList<SubBanner>(), repository.findAllByPlacement(placement))
|
||||||
assertEquals(emptyList<SubBanner>(), repository.findVisibleByPlacement(placement, now))
|
assertEquals(emptyList<SubBanner>(), repository.findVisibleByPlacement(placement, Lang.KO, now))
|
||||||
val otherPlacement = SubBannerPlacement.values().first { it != placement }
|
val otherPlacement = SubBannerPlacement.values().first { it != placement }
|
||||||
val saved = (1..25).map { index ->
|
val saved = (1..25).map { index ->
|
||||||
repository.save(banner(otherPlacement))
|
repository.save(banner(otherPlacement))
|
||||||
@@ -113,7 +152,7 @@ class SubBannerPersistenceTest @Autowired constructor(
|
|||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
assertEquals(saved + future + expired, repository.findAllByPlacement(placement))
|
assertEquals(saved + future + expired, repository.findAllByPlacement(placement))
|
||||||
assertEquals(saved, repository.findVisibleByPlacement(placement, now))
|
assertEquals(saved, repository.findVisibleByPlacement(placement, Lang.KO, now))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest(name = "{0}")
|
@ParameterizedTest(name = "{0}")
|
||||||
@@ -132,7 +171,7 @@ class SubBannerPersistenceTest @Autowired constructor(
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
if (visible) listOf(candidate) else emptyList(),
|
if (visible) listOf(candidate) else emptyList(),
|
||||||
repository.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION, now),
|
repository.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION, Lang.KO, now),
|
||||||
name
|
name
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -217,10 +256,10 @@ class SubBannerPersistenceTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
active.forEach { assertEquals(it, repository.findById(requireNotNull(it.id))) }
|
active.forEach { assertEquals(it, repository.findById(requireNotNull(it.id))) }
|
||||||
assertEquals(active, repository.findAllByPlacement(placement))
|
assertEquals(active, repository.findAllByPlacement(placement))
|
||||||
assertEquals(active.take(2), repository.findVisibleByPlacement(placement, now))
|
assertEquals(active.take(2), repository.findVisibleByPlacement(placement, Lang.KO, now))
|
||||||
assertEquals(other, repository.findById(requireNotNull(other.id)))
|
assertEquals(other, repository.findById(requireNotNull(other.id)))
|
||||||
assertEquals(listOf(other), repository.findAllByPlacement(other.placement))
|
assertEquals(listOf(other), repository.findAllByPlacement(other.placement))
|
||||||
assertEquals(listOf(other), repository.findVisibleByPlacement(other.placement, now))
|
assertEquals(listOf(other), repository.findVisibleByPlacement(other.placement, Lang.KO, now))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -243,7 +282,7 @@ class SubBannerPersistenceTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
assertNull(repository.findById(id))
|
assertNull(repository.findById(id))
|
||||||
assertEquals(emptyList<SubBanner>(), repository.findAllByPlacement(saved.placement))
|
assertEquals(emptyList<SubBanner>(), repository.findAllByPlacement(saved.placement))
|
||||||
assertEquals(emptyList<SubBanner>(), repository.findVisibleByPlacement(saved.placement, LocalDateTime.now()))
|
assertEquals(emptyList<SubBanner>(), repository.findVisibleByPlacement(saved.placement, Lang.KO, LocalDateTime.now()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -297,7 +336,7 @@ class SubBannerPersistenceTest @Autowired constructor(
|
|||||||
val readRepository = DefaultSubBannerRepository(readContext, JPAQueryFactory(readContext))
|
val readRepository = DefaultSubBannerRepository(readContext, JPAQueryFactory(readContext))
|
||||||
assertNull(readRepository.findById(id))
|
assertNull(readRepository.findById(id))
|
||||||
assertEquals(emptyList<SubBanner>(), readRepository.findAllByPlacement(saved.placement))
|
assertEquals(emptyList<SubBanner>(), readRepository.findAllByPlacement(saved.placement))
|
||||||
assertEquals(emptyList<SubBanner>(), readRepository.findVisibleByPlacement(saved.placement, now))
|
assertEquals(emptyList<SubBanner>(), readRepository.findVisibleByPlacement(saved.placement, Lang.KO, now))
|
||||||
readContext.transaction.commit()
|
readContext.transaction.commit()
|
||||||
} finally {
|
} finally {
|
||||||
listOf(updateContext, deleteContext, readContext).forEach { context ->
|
listOf(updateContext, deleteContext, readContext).forEach { context ->
|
||||||
@@ -341,17 +380,26 @@ class SubBannerPersistenceTest @Autowired constructor(
|
|||||||
assertTrue(columns.next(), "CREATE DDL must contain is_active")
|
assertTrue(columns.next(), "CREATE DDL must contain is_active")
|
||||||
assertEquals("NO", columns.getString("IS_NULLABLE"))
|
assertEquals("NO", columns.getString("IS_NULLABLE"))
|
||||||
}
|
}
|
||||||
|
connection.metaData.getColumns(null, null, "sub_banner", "lang").use { columns ->
|
||||||
|
assertTrue(columns.next(), "CREATE DDL must contain lang")
|
||||||
|
assertEquals("NO", columns.getString("IS_NULLABLE"))
|
||||||
|
assertEquals(10, columns.getInt("COLUMN_SIZE"))
|
||||||
|
}
|
||||||
statement.executeUpdate(
|
statement.executeUpdate(
|
||||||
"insert into sub_banner (placement, image_path, web_url) " +
|
"insert into sub_banner (placement, image_path, web_url) " +
|
||||||
"values ('HOME_RECOMMENDATION', 'sub-banner/image.png', 'https://example.com/banner')"
|
"values ('HOME_RECOMMENDATION', 'sub-banner/image.png', 'https://example.com/banner')"
|
||||||
)
|
)
|
||||||
statement.executeQuery("select is_active from sub_banner").use { rows ->
|
statement.executeQuery("select is_active, lang from sub_banner").use { rows ->
|
||||||
assertTrue(rows.next())
|
assertTrue(rows.next())
|
||||||
assertTrue(rows.getBoolean("is_active"))
|
assertTrue(rows.getBoolean("is_active"))
|
||||||
|
assertEquals("KO", rows.getString("lang"))
|
||||||
}
|
}
|
||||||
assertThrows(SQLException::class.java) {
|
assertThrows(SQLException::class.java) {
|
||||||
statement.executeUpdate("update sub_banner set is_active = null")
|
statement.executeUpdate("update sub_banner set is_active = null")
|
||||||
}
|
}
|
||||||
|
assertThrows(SQLException::class.java) {
|
||||||
|
statement.executeUpdate("update sub_banner set lang = null")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.subbanner.domain
|
package kr.co.vividnext.sodalive.v2.subbanner.domain
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertThrows
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class SubBannerTest {
|
class SubBannerTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("기존 생성자는 KO 언어를 기본으로 가진다")
|
||||||
|
fun shouldDefaultLanguageToKorean() {
|
||||||
|
val stored = jacksonObjectMapper().valueToTree<JsonNode>(banner("https://example.com", null, null))
|
||||||
|
assertEquals("KO", stored.path("lang").asText())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("세 링크 중 하나 이상이 있는 7개 조합은 허용하고 모두 null이면 거부한다")
|
@DisplayName("세 링크 중 하나 이상이 있는 7개 조합은 허용하고 모두 null이면 거부한다")
|
||||||
fun shouldRejectAllEmptyLinksAndAllowSevenNonEmptyCombinations() {
|
fun shouldRejectAllEmptyLinksAndAllowSevenNonEmptyCombinations() {
|
||||||
|
|||||||
Reference in New Issue
Block a user