From 98e68fc9942a174d48b9e32fea322a4a089d7663 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 27 Oct 2026 20:48:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(banner):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=EA=B8=B0=EA=B0=84=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=99=80=20=EC=9E=85=EB=A0=A5=20=EA=B2=80=EC=A6=9D=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../banner/AdminContentBannerRepository.kt | 4 +- .../banner/AdminContentBannerService.kt | 58 ++- .../banner/CreateContentBannerRequest.kt | 4 +- .../banner/GetAdminContentBannerResponse.kt | 39 +- .../banner/UpdateContentBannerRequest.kt | 4 +- ...nContentBannerControllerIntegrationTest.kt | 342 ++++++++++++++++++ 6 files changed, 443 insertions(+), 8 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerControllerIntegrationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerRepository.kt index 00933fce..56fde52a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerRepository.kt @@ -46,7 +46,9 @@ class AdminContentBannerQueryRepositoryImpl( audioContentBanner.series.id, audioContentBanner.series.title, audioContentBanner.link, - audioContentBanner.isAdult + audioContentBanner.isAdult, + audioContentBanner.startDate, + audioContentBanner.endDate ) ) .from(audioContentBanner) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt index bcaeadcf..506cf637 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt @@ -1,5 +1,6 @@ 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.series.AdminContentSeriesRepository 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.transaction.annotation.Transactional 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 class AdminContentBannerService( @@ -31,7 +37,12 @@ class AdminContentBannerService( ) { @Transactional 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) { throw SodaException(messageKey = "admin.content.banner.creator_required") } @@ -79,6 +90,8 @@ class AdminContentBannerService( audioContentBanner.creator = creator audioContentBanner.series = series audioContentBanner.tab = tab + audioContentBanner.startDate = startDate + audioContentBanner.endDate = endDate repository.save(audioContentBanner) val fileName = generateFileName() @@ -92,10 +105,20 @@ class AdminContentBannerService( @Transactional 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) ?: 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) { val fileName = generateFileName() val imagePath = s3Uploader.upload( @@ -106,6 +129,9 @@ class AdminContentBannerService( audioContentBanner.thumbnailImage = imagePath } + audioContentBanner.startDate = startDate + audioContentBanner.endDate = endDate + if (request.isAdult != null) { audioContentBanner.isAdult = request.isAdult } @@ -185,4 +211,32 @@ class AdminContentBannerService( fun getAudioContentMainBannerList(tabId: Long?): List { 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") + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt index a5a81fbf..2b860950 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt @@ -11,5 +11,7 @@ data class CreateContentBannerRequest( val creatorId: Long?, val seriesId: Long?, val link: String?, - val isAdult: Boolean + val isAdult: Boolean, + val startDate: String? = null, + val endDate: String? = null ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/GetAdminContentBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/GetAdminContentBannerResponse.kt index 2ba93483..227e2351 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/GetAdminContentBannerResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/GetAdminContentBannerResponse.kt @@ -2,8 +2,11 @@ package kr.co.vividnext.sodalive.admin.content.banner import com.querydsl.core.annotations.QueryProjection 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 tabId: Long?, val type: AudioContentBannerType, @@ -15,5 +18,35 @@ data class GetAdminContentBannerResponse @QueryProjection constructor( val seriesId: Long?, val seriesTitle: 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")) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/UpdateContentBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/UpdateContentBannerRequest.kt index 4390788e..a6fcb703 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/UpdateContentBannerRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/UpdateContentBannerRequest.kt @@ -11,5 +11,7 @@ data class UpdateContentBannerRequest( val seriesId: Long?, val link: String?, val isAdult: Boolean?, - val isActive: Boolean? + val isActive: Boolean?, + val startDate: String? = null, + val endDate: String? = null ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerControllerIntegrationTest.kt new file mode 100644 index 00000000..2dec81ca --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerControllerIntegrationTest.kt @@ -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 = 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 = 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, + 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) = + MockMultipartFile("request", "", "application/json", objectMapper.writeValueAsBytes(fields)) + + private fun listBanners(tabId: Long? = null): List { + 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 { + 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 = 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 = listOf(emptyMap(), emptyList()) + .flatMap { value -> listOf("startDate", "endDate").map { field -> Arguments.of(field, value) } }.stream() + + @JvmStatic + private fun invalidReservations(): Stream = 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() + } +}