feat(banner): 관리자 예약 기간 관리와 입력 검증을 추가한다
This commit is contained in:
+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