feat(banner): 관리자 예약 기간 관리와 입력 검증을 추가한다

This commit is contained in:
2026-10-27 20:48:03 +09:00
parent 9a4607449c
commit 98e68fc994
6 changed files with 443 additions and 8 deletions
@@ -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()
}
}