feat(banner): 관리자 예약 기간 관리와 입력 검증을 추가한다
This commit is contained in:
+3
-1
@@ -46,7 +46,9 @@ class AdminContentBannerQueryRepositoryImpl(
|
|||||||
audioContentBanner.series.id,
|
audioContentBanner.series.id,
|
||||||
audioContentBanner.series.title,
|
audioContentBanner.series.title,
|
||||||
audioContentBanner.link,
|
audioContentBanner.link,
|
||||||
audioContentBanner.isAdult
|
audioContentBanner.isAdult,
|
||||||
|
audioContentBanner.startDate,
|
||||||
|
audioContentBanner.endDate
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(audioContentBanner)
|
.from(audioContentBanner)
|
||||||
|
|||||||
+56
-2
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.content.banner
|
package kr.co.vividnext.sodalive.admin.content.banner
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
|
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
|
||||||
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
|
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
|
||||||
@@ -15,6 +16,11 @@ import org.springframework.data.repository.findByIdOrNull
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
|
import java.time.format.ResolverStyle
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class AdminContentBannerService(
|
class AdminContentBannerService(
|
||||||
@@ -31,7 +37,12 @@ class AdminContentBannerService(
|
|||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createAudioContentMainBanner(image: MultipartFile, requestString: String) {
|
fun createAudioContentMainBanner(image: MultipartFile, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, CreateContentBannerRequest::class.java)
|
val fields = objectMapper.readTree(requestString)
|
||||||
|
validateReservationTypes(fields)
|
||||||
|
val request = objectMapper.treeToValue(fields, CreateContentBannerRequest::class.java)
|
||||||
|
val startDate = parseReservationDate(request.startDate)
|
||||||
|
val endDate = parseReservationDate(request.endDate)?.withSecond(59)
|
||||||
|
validateReservationRange(startDate, endDate)
|
||||||
if (request.type == AudioContentBannerType.CREATOR && request.creatorId == null) {
|
if (request.type == AudioContentBannerType.CREATOR && request.creatorId == null) {
|
||||||
throw SodaException(messageKey = "admin.content.banner.creator_required")
|
throw SodaException(messageKey = "admin.content.banner.creator_required")
|
||||||
}
|
}
|
||||||
@@ -79,6 +90,8 @@ class AdminContentBannerService(
|
|||||||
audioContentBanner.creator = creator
|
audioContentBanner.creator = creator
|
||||||
audioContentBanner.series = series
|
audioContentBanner.series = series
|
||||||
audioContentBanner.tab = tab
|
audioContentBanner.tab = tab
|
||||||
|
audioContentBanner.startDate = startDate
|
||||||
|
audioContentBanner.endDate = endDate
|
||||||
repository.save(audioContentBanner)
|
repository.save(audioContentBanner)
|
||||||
|
|
||||||
val fileName = generateFileName()
|
val fileName = generateFileName()
|
||||||
@@ -92,10 +105,20 @@ class AdminContentBannerService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateAudioContentMainBanner(image: MultipartFile?, requestString: String) {
|
fun updateAudioContentMainBanner(image: MultipartFile?, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, UpdateContentBannerRequest::class.java)
|
val fields = objectMapper.readTree(requestString)
|
||||||
|
validateReservationTypes(fields)
|
||||||
|
val request = objectMapper.treeToValue(fields, UpdateContentBannerRequest::class.java)
|
||||||
val audioContentBanner = repository.findByIdOrNull(request.id)
|
val audioContentBanner = repository.findByIdOrNull(request.id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
|
val startDate = if (fields.has("startDate")) parseReservationDate(request.startDate) else audioContentBanner.startDate
|
||||||
|
val endDate = if (fields.has("endDate")) {
|
||||||
|
parseReservationDate(request.endDate)?.withSecond(59)
|
||||||
|
} else {
|
||||||
|
audioContentBanner.endDate
|
||||||
|
}
|
||||||
|
validateReservationRange(startDate, endDate)
|
||||||
|
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
val fileName = generateFileName()
|
val fileName = generateFileName()
|
||||||
val imagePath = s3Uploader.upload(
|
val imagePath = s3Uploader.upload(
|
||||||
@@ -106,6 +129,9 @@ class AdminContentBannerService(
|
|||||||
audioContentBanner.thumbnailImage = imagePath
|
audioContentBanner.thumbnailImage = imagePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audioContentBanner.startDate = startDate
|
||||||
|
audioContentBanner.endDate = endDate
|
||||||
|
|
||||||
if (request.isAdult != null) {
|
if (request.isAdult != null) {
|
||||||
audioContentBanner.isAdult = request.isAdult
|
audioContentBanner.isAdult = request.isAdult
|
||||||
}
|
}
|
||||||
@@ -185,4 +211,32 @@ class AdminContentBannerService(
|
|||||||
fun getAudioContentMainBannerList(tabId: Long?): List<GetAdminContentBannerResponse> {
|
fun getAudioContentMainBannerList(tabId: Long?): List<GetAdminContentBannerResponse> {
|
||||||
return repository.getAudioContentMainBannerList(tabId = tabId ?: 1)
|
return repository.getAudioContentMainBannerList(tabId = tabId ?: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun validateReservationTypes(fields: JsonNode) {
|
||||||
|
if (listOf(fields["startDate"], fields["endDate"]).any { it != null && !it.isNull && !it.isTextual }) {
|
||||||
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseReservationDate(value: String?): LocalDateTime? {
|
||||||
|
if (value == null) return null
|
||||||
|
if (value.length != 16) throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
return try {
|
||||||
|
LocalDateTime.parse(
|
||||||
|
value,
|
||||||
|
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm").withResolverStyle(ResolverStyle.STRICT)
|
||||||
|
)
|
||||||
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
|
.toLocalDateTime()
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateReservationRange(startDate: LocalDateTime?, endDate: LocalDateTime?) {
|
||||||
|
if (startDate != null && endDate != null && startDate > endDate) {
|
||||||
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -11,5 +11,7 @@ data class CreateContentBannerRequest(
|
|||||||
val creatorId: Long?,
|
val creatorId: Long?,
|
||||||
val seriesId: Long?,
|
val seriesId: Long?,
|
||||||
val link: String?,
|
val link: String?,
|
||||||
val isAdult: Boolean
|
val isAdult: Boolean,
|
||||||
|
val startDate: String? = null,
|
||||||
|
val endDate: String? = null
|
||||||
)
|
)
|
||||||
|
|||||||
+36
-3
@@ -2,8 +2,11 @@ package kr.co.vividnext.sodalive.admin.content.banner
|
|||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
data class GetAdminContentBannerResponse @QueryProjection constructor(
|
data class GetAdminContentBannerResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val tabId: Long?,
|
val tabId: Long?,
|
||||||
val type: AudioContentBannerType,
|
val type: AudioContentBannerType,
|
||||||
@@ -15,5 +18,35 @@ data class GetAdminContentBannerResponse @QueryProjection constructor(
|
|||||||
val seriesId: Long?,
|
val seriesId: Long?,
|
||||||
val seriesTitle: String?,
|
val seriesTitle: String?,
|
||||||
val link: String?,
|
val link: String?,
|
||||||
val isAdult: Boolean
|
val isAdult: Boolean,
|
||||||
)
|
val startDate: String? = null,
|
||||||
|
val endDate: String? = null
|
||||||
|
) {
|
||||||
|
@QueryProjection
|
||||||
|
constructor(
|
||||||
|
id: Long,
|
||||||
|
tabId: Long?,
|
||||||
|
type: AudioContentBannerType,
|
||||||
|
thumbnailImageUrl: String,
|
||||||
|
eventId: Long?,
|
||||||
|
eventThumbnailImage: String?,
|
||||||
|
creatorId: Long?,
|
||||||
|
creatorNickname: String?,
|
||||||
|
seriesId: Long?,
|
||||||
|
seriesTitle: String?,
|
||||||
|
link: String?,
|
||||||
|
isAdult: Boolean,
|
||||||
|
startDate: LocalDateTime?,
|
||||||
|
endDate: LocalDateTime?
|
||||||
|
) : this(
|
||||||
|
id, tabId, type, thumbnailImageUrl, eventId, eventThumbnailImage, creatorId, creatorNickname,
|
||||||
|
seriesId, seriesTitle, link, isAdult, formatDate(startDate), formatDate(endDate)
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private fun formatDate(value: LocalDateTime?): String? = value
|
||||||
|
?.atZone(ZoneId.of("UTC"))
|
||||||
|
?.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
||||||
|
?.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -11,5 +11,7 @@ data class UpdateContentBannerRequest(
|
|||||||
val seriesId: Long?,
|
val seriesId: Long?,
|
||||||
val link: String?,
|
val link: String?,
|
||||||
val isAdult: Boolean?,
|
val isAdult: Boolean?,
|
||||||
val isActive: Boolean?
|
val isActive: Boolean?,
|
||||||
|
val startDate: String? = null,
|
||||||
|
val endDate: String? = null
|
||||||
)
|
)
|
||||||
|
|||||||
+342
@@ -0,0 +1,342 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.banner
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
||||||
|
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
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.Arguments
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.mock.web.MockMultipartFile
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
|
import org.springframework.test.annotation.DirtiesContext
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.stream.Stream
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
properties = [
|
||||||
|
"cloud.aws.cloud-front.host=https://cdn.test",
|
||||||
|
"cloud.aws.s3.bucket=test-bucket",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:admin-content-banner-controller-integration;" +
|
||||||
|
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
|
class AdminContentBannerControllerIntegrationTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc,
|
||||||
|
private val repository: AdminContentBannerRepository,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val jdbcTemplate: JdbcTemplate,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val transactionTemplate: TransactionTemplate,
|
||||||
|
private val tabRepository: AdminContentMainTabRepository
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var s3Uploader: S3Uploader
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
repository.deleteAll()
|
||||||
|
tabRepository.deleteAll()
|
||||||
|
jdbcTemplate.execute("ALTER TABLE content_banner ALTER COLUMN start_date TIMESTAMP(0)")
|
||||||
|
jdbcTemplate.execute("ALTER TABLE content_banner ALTER COLUMN end_date TIMESTAMP(0)")
|
||||||
|
Mockito.`when`(
|
||||||
|
s3Uploader.upload(anyInputStream(), Mockito.anyString(), Mockito.anyString(), Mockito.isNull())
|
||||||
|
).thenAnswer { it.getArgument(2) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun cleanUp() {
|
||||||
|
repository.deleteAll()
|
||||||
|
tabRepository.deleteAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"OMITTED, OMITTED, , ",
|
||||||
|
"NULL, NULL, , ",
|
||||||
|
"2026-10-27 00:00, OMITTED, 2026-10-26T15:00:00, ",
|
||||||
|
"OMITTED, 2026-10-28 23:59, , 2026-10-28T14:59:59",
|
||||||
|
"2026-10-27 00:00, 2026-10-28 23:59, 2026-10-26T15:00:00, 2026-10-28T14:59:59",
|
||||||
|
"2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27T14:59:00, 2026-10-27T14:59:59"
|
||||||
|
)
|
||||||
|
@DisplayName("관리자 multipart 생성과 목록은 선택적 예약기간을 KST 문자열 또는 JSON null로 반환한다")
|
||||||
|
fun shouldCreateAndListOptionalReservation(
|
||||||
|
startDate: String,
|
||||||
|
endDate: String,
|
||||||
|
expectedStart: String?,
|
||||||
|
expectedEnd: String?
|
||||||
|
) {
|
||||||
|
val id = createBanner(dateFields(startDate, endDate))
|
||||||
|
|
||||||
|
val banner = listBanners().single()
|
||||||
|
assertDate(banner, "startDate", startDate)
|
||||||
|
assertDate(banner, "endDate", endDate)
|
||||||
|
assertEquals("LINK", banner["type"].asText())
|
||||||
|
assertEquals("https://banner.test", banner["link"].asText())
|
||||||
|
assertTrue(banner["thumbnailImageUrl"].asText().startsWith("https://cdn.test/audio_content_banner/"))
|
||||||
|
val stored = reloadBanner(id)
|
||||||
|
assertEquals(expectedStart?.let(LocalDateTime::parse), stored.startDate)
|
||||||
|
assertEquals(expectedEnd?.let(LocalDateTime::parse), stored.endDate)
|
||||||
|
assertEquals(0, stored.startDate?.nano ?: 0)
|
||||||
|
assertEquals(0, stored.endDate?.nano ?: 0)
|
||||||
|
assertEquals(Lang.JA, stored.lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"OMITTED, OMITTED, 2026-10-28 00:00, 2026-10-29 23:59",
|
||||||
|
"NULL, OMITTED, NULL, 2026-10-29 23:59",
|
||||||
|
"OMITTED, NULL, 2026-10-28 00:00, NULL",
|
||||||
|
"NULL, NULL, NULL, NULL",
|
||||||
|
"2026-10-27 00:00, OMITTED, 2026-10-27 00:00, 2026-10-29 23:59",
|
||||||
|
"OMITTED, 2026-10-30 23:59, 2026-10-28 00:00, 2026-10-30 23:59",
|
||||||
|
"2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27 23:59",
|
||||||
|
"NULL, 2026-10-26 00:00, NULL, 2026-10-26 00:00",
|
||||||
|
"2026-10-30 00:00, NULL, 2026-10-30 00:00, NULL"
|
||||||
|
)
|
||||||
|
@DisplayName("관리자 multipart 수정은 생략한 경계를 보존하고 명시 null만 해제한다")
|
||||||
|
fun shouldMergeAndPersistReservationUpdates(
|
||||||
|
startDate: String,
|
||||||
|
endDate: String,
|
||||||
|
expectedStart: String,
|
||||||
|
expectedEnd: String
|
||||||
|
) {
|
||||||
|
val id = createScheduledBanner()
|
||||||
|
Mockito.clearInvocations(s3Uploader)
|
||||||
|
|
||||||
|
mockMvc.perform(updateRequest(id, dateFields(startDate, endDate), withImage = false))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
|
||||||
|
val banner = listBanners().single()
|
||||||
|
assertDate(banner, "startDate", expectedStart)
|
||||||
|
assertDate(banner, "endDate", expectedEnd)
|
||||||
|
val stored = reloadBanner(id)
|
||||||
|
assertEquals(expectedUtc(expectedStart, 0), stored.startDate)
|
||||||
|
assertEquals(expectedUtc(expectedEnd, 59), stored.endDate)
|
||||||
|
Mockito.verifyNoInteractions(s3Uploader)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("invalidReservations", "invalidReservationTypes")
|
||||||
|
@DisplayName("잘못된 생성 예약 형식과 달력 날짜는 업로드나 저장 전에 공통 오류로 거부한다")
|
||||||
|
fun shouldRejectInvalidCreateDatesBeforeUpload(field: String, value: Any) {
|
||||||
|
assertInvalidRequest(
|
||||||
|
multipart("/admin/audio-content/banner")
|
||||||
|
.file(image())
|
||||||
|
.file(requestPart(createFields() + mapOf(field to value)))
|
||||||
|
.with(user("admin").roles("ADMIN"))
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(0, repository.count())
|
||||||
|
Mockito.verifyNoInteractions(s3Uploader)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("invalidReservations", "invalidReservationTypes")
|
||||||
|
@DisplayName("잘못된 수정 예약 형식과 달력 날짜는 이미지와 기존 엔티티를 변경하지 않는다")
|
||||||
|
fun shouldRejectInvalidUpdateDatesBeforeUploadOrMutation(field: String, value: Any) {
|
||||||
|
val id = createScheduledBanner()
|
||||||
|
val before = storedRow(id)
|
||||||
|
Mockito.clearInvocations(s3Uploader)
|
||||||
|
|
||||||
|
assertInvalidRequest(updateRequest(id, mapOf(field to value, "isAdult" to true, "isActive" to false)))
|
||||||
|
|
||||||
|
assertEquals(before, storedRow(id))
|
||||||
|
Mockito.verifyNoInteractions(s3Uploader)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("역전된 생성 예약은 이미지 업로드와 저장 전에 공통 오류로 거부한다")
|
||||||
|
fun shouldRejectReversedCreateRangeBeforeUpload() {
|
||||||
|
assertInvalidRequest(
|
||||||
|
multipart("/admin/audio-content/banner")
|
||||||
|
.file(image())
|
||||||
|
.file(requestPart(createFields() + dateFields("2026-10-29 00:00", "2026-10-28 23:59")))
|
||||||
|
.with(user("admin").roles("ADMIN"))
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(0, repository.count())
|
||||||
|
Mockito.verifyNoInteractions(s3Uploader)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource("2026-10-30 00:00, OMITTED", "OMITTED, 2026-10-27 23:59", "2026-10-30 00:00, 2026-10-29 23:59")
|
||||||
|
@DisplayName("수정은 기존 경계와 합친 유효 범위의 역전을 업로드나 변경 전에 거부한다")
|
||||||
|
fun shouldRejectReversedEffectiveUpdateRange(startDate: String, endDate: String) {
|
||||||
|
val id = createScheduledBanner()
|
||||||
|
val before = storedRow(id)
|
||||||
|
Mockito.clearInvocations(s3Uploader)
|
||||||
|
|
||||||
|
assertInvalidRequest(
|
||||||
|
updateRequest(id, dateFields(startDate, endDate) + mapOf("isAdult" to true, "isActive" to false))
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(before, storedRow(id))
|
||||||
|
Mockito.verifyNoInteractions(s3Uploader)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 목록은 미래·만료·무기한 예약을 숨기지 않고 기존 active와 tab 조건만 유지한다")
|
||||||
|
fun shouldListAllReservationPeriodsWithExistingActiveAndTabConditions() {
|
||||||
|
val future = createBanner(dateFields("2030-01-01 00:00", "2030-01-02 23:59"))
|
||||||
|
val expired = createBanner(dateFields("2020-01-01 00:00", "2020-01-02 23:59"))
|
||||||
|
val unscheduled = createBanner()
|
||||||
|
val inactive = createBanner(dateFields("2030-01-01 00:00", "OMITTED"))
|
||||||
|
mockMvc.perform(updateRequest(inactive, mapOf("isActive" to false), withImage = false))
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
tabRepository.saveAndFlush(AudioContentMainTab(title = "default", isActive = true))
|
||||||
|
val tab = tabRepository.saveAndFlush(AudioContentMainTab(title = "other", isActive = true))
|
||||||
|
val tabId = tab.id!!
|
||||||
|
assertTrue(tabId > 1)
|
||||||
|
val otherTab = createBanner(dateFields("2020-01-01 00:00", "2020-01-02 23:59") + mapOf("tabId" to tabId))
|
||||||
|
|
||||||
|
assertEquals(setOf(future, expired, unscheduled), listBanners().map { it["id"].asLong() }.toSet())
|
||||||
|
assertEquals(setOf(future, expired, unscheduled), listBanners(1L).map { it["id"].asLong() }.toSet())
|
||||||
|
assertEquals(listOf(otherTab), listBanners(tabId).map { it["id"].asLong() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createBanner(fields: Map<String, Any?> = emptyMap()): Long {
|
||||||
|
val existingIds = repository.findAll().map { it.id }.toSet()
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart("/admin/audio-content/banner")
|
||||||
|
.file(image())
|
||||||
|
.file(requestPart(createFields() + fields))
|
||||||
|
.with(user("admin").roles("ADMIN"))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
return repository.findAll().single { it.id !in existingIds }.id!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFields(): Map<String, Any?> = mapOf(
|
||||||
|
"type" to "LINK",
|
||||||
|
"lang" to "ja",
|
||||||
|
"link" to "https://banner.test",
|
||||||
|
"isAdult" to false
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createScheduledBanner(): Long = createBanner(dateFields("2026-10-28 00:00", "2026-10-29 23:59"))
|
||||||
|
|
||||||
|
private fun updateRequest(
|
||||||
|
id: Long,
|
||||||
|
fields: Map<String, Any?>,
|
||||||
|
withImage: Boolean = true
|
||||||
|
): MockHttpServletRequestBuilder {
|
||||||
|
val request = multipart("/admin/audio-content/banner")
|
||||||
|
.file(requestPart(mapOf("id" to id) + fields))
|
||||||
|
if (withImage) request.file(image())
|
||||||
|
return request.with {
|
||||||
|
it.method = "PUT"
|
||||||
|
it
|
||||||
|
}.with(user("admin").roles("ADMIN"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertInvalidRequest(request: MockHttpServletRequestBuilder) {
|
||||||
|
val result = mockMvc.perform(request)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andReturn()
|
||||||
|
assertTrue(result.resolvedException is SodaException)
|
||||||
|
assertEquals("common.error.invalid_request", (result.resolvedException as SodaException).messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun image() = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
|
||||||
|
|
||||||
|
private fun requestPart(fields: Map<String, Any?>) =
|
||||||
|
MockMultipartFile("request", "", "application/json", objectMapper.writeValueAsBytes(fields))
|
||||||
|
|
||||||
|
private fun listBanners(tabId: Long? = null): List<JsonNode> {
|
||||||
|
val request = get("/admin/audio-content/banner").with(user("admin").roles("ADMIN"))
|
||||||
|
if (tabId != null) request.param("tabId", tabId.toString())
|
||||||
|
val result = mockMvc.perform(request)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andReturn()
|
||||||
|
return objectMapper.readTree(result.response.contentAsString)["data"].toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertDate(banner: JsonNode, field: String, expected: String) {
|
||||||
|
assertTrue(banner.has(field), "Admin HTTP response must include $field")
|
||||||
|
if (expected == "OMITTED" || expected == "NULL") {
|
||||||
|
assertTrue(banner[field].isNull, "$field must be JSON null")
|
||||||
|
} else {
|
||||||
|
assertEquals(expected, banner[field].asText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dateFields(startDate: String, endDate: String): Map<String, Any?> {
|
||||||
|
return mapOf("startDate" to startDate, "endDate" to endDate)
|
||||||
|
.filterValues { it != "OMITTED" }
|
||||||
|
.mapValues { if (it.value == "NULL") null else it.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reloadBanner(id: Long): AudioContentBanner = transactionTemplate.execute {
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
repository.findById(id).orElseThrow()
|
||||||
|
}!!
|
||||||
|
|
||||||
|
private fun storedRow(id: Long): Map<String, Any> = jdbcTemplate.queryForMap("SELECT * FROM content_banner WHERE id = ?", id)
|
||||||
|
|
||||||
|
private fun expectedUtc(value: String, second: Int): LocalDateTime? = if (value == "NULL") {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
LocalDateTime.parse(value, DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm")).minusHours(9).withSecond(second)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun anyInputStream(): InputStream = Mockito.any(InputStream::class.java) ?: byteArrayOf().inputStream()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
private fun invalidReservationTypes(): Stream<Arguments> = listOf(emptyMap<String, Any>(), emptyList<Any>())
|
||||||
|
.flatMap { value -> listOf("startDate", "endDate").map { field -> Arguments.of(field, value) } }.stream()
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun invalidReservations(): Stream<Arguments> = listOf(
|
||||||
|
"not-a-date",
|
||||||
|
"2026-02-29 10:00",
|
||||||
|
"2026-04-31 10:00",
|
||||||
|
"2026-13-01 10:00",
|
||||||
|
"2026-10-27 24:00",
|
||||||
|
"2026-10-27T10:00",
|
||||||
|
"2026-10-27 10:00:00",
|
||||||
|
"2026-1-01 10:00",
|
||||||
|
"+02026-10-27 10:00",
|
||||||
|
" 2026-10-27 10:00",
|
||||||
|
""
|
||||||
|
).flatMap { value -> listOf("startDate", "endDate").map { field -> Arguments.of(field, value) } }.stream()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user