feat(subbanner): 관리자 CRUD와 이미지·기간 검증을 추가한다

This commit is contained in:
2026-10-29 23:22:04 +09:00
parent aa6863f4f9
commit a30196e0c9
5 changed files with 894 additions and 0 deletions
@@ -0,0 +1,596 @@
package kr.co.vividnext.sodalive.v2.subbanner.adapter.`in`.web
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
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.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.core.io.ByteArrayResource
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.support.TransactionTemplate
import org.springframework.util.LinkedMultiValueMap
import java.io.InputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Base64
import java.util.UUID
import javax.persistence.EntityManager
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"cloud.aws.s3.bucket=test-bucket",
"spring.datasource.url=jdbc:h2:mem:admin-sub-banner-http;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class AdminSubBannerControllerIntegrationTest @Autowired constructor(
private val restTemplate: TestRestTemplate,
private val objectMapper: ObjectMapper,
private val repository: SubBannerRepository,
private val jdbcTemplate: JdbcTemplate,
private val transactionTemplate: TransactionTemplate,
private val entityManager: EntityManager,
private val memberRepository: MemberRepository,
private val tokenProvider: TokenProvider,
private val tokenRepository: MemberTokenRepository
) {
@LocalServerPort
private var port: Int = 0
@MockBean
private lateinit var s3Uploader: S3Uploader
private lateinit var adminToken: String
private val memberIds = mutableListOf<Long>()
@BeforeEach
fun setUp() {
jdbcTemplate.update("delete from sub_banner")
jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN start_date TIMESTAMP(0)")
jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN end_date TIMESTAMP(0)")
adminToken = tokenFor(MemberRole.ADMIN)
Mockito.`when`(
s3Uploader.upload(anyInputStream(), Mockito.anyString(), Mockito.anyString(), Mockito.isNull())
).thenAnswer { invocation ->
assertEquals("test-bucket", invocation.getArgument<String>(1))
assertTrue(invocation.getArgument<String>(2).matches(Regex("sub_banner/[A-Za-z0-9_=-]+")))
assertEquals(PNG.toList(), invocation.getArgument<InputStream>(0).readBytes().toList())
invocation.getArgument<String>(2)
}
}
@AfterEach
fun cleanUp() {
jdbcTemplate.update("delete from sub_banner")
tokenRepository.deleteAll()
memberRepository.deleteAllById(memberIds)
memberIds.clear()
}
@Test
@DisplayName("실제 JWT HTTP CRUD는 관리자 8필드·삭제 null을 유지하며 소프트 삭제 후 수정·재삭제를 거부한다")
fun shouldCreateListUpdateAndSoftDeleteWithExactAdminResponse() {
val createdResponse = send(HttpMethod.POST)
evidence("POST", createdResponse)
val created = success(createdResponse)
assertAdminFields(created)
val id = created["id"].asLong()
val stored = reload(id)
assertEquals("https://cdn.test/${stored.imagePath}", created["imageUrl"].asText())
assertEquals(WEB, stored.webUrl)
assertNull(stored.startDate)
assertNull(stored.endDate)
assertEquals(listOf(created), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList())
val updatedResponse = send(
HttpMethod.PUT,
"/$id",
baseFields() + mapOf("placement" to "CONTENT_RECOMMENDATION", "webUrl" to null, "iosDeepLink" to "custom:promo") +
dates("2026-10-28 00:00", "2026-10-29 23:59")
)
evidence("PUT", updatedResponse)
val updated = success(updatedResponse)
assertAdminFields(updated)
assertEquals(id, updated["id"].asLong())
assertTrue(updated["webUrl"].isNull)
assertEquals("custom:promo", updated["iosDeepLink"].asText())
assertNotEquals(stored.imagePath, reload(id).imagePath)
assertEquals(emptyList<JsonNode>(), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList())
assertEquals(listOf(updated), success(send(HttpMethod.GET, "?placement=CONTENT_RECOMMENDATION")).toList())
val beforeDelete = storedRows().single()
Mockito.clearInvocations(s3Uploader)
val deleted = send(HttpMethod.DELETE, "/$id")
evidence("DELETE", deleted)
assertTrue(success(deleted, allowNullData = true).isNull)
assertNull(transactionTemplate.execute { repository.findById(id) })
assertEquals(1L, rowCount(), "Soft delete must retain the original row")
val retained = storedRows().single()
assertEquals(true, beforeDelete["is_active"])
assertEquals(false, retained["is_active"])
assertEquals(beforeDelete - setOf("is_active", "updated_at"), retained - setOf("is_active", "updated_at"))
assertNotEquals(beforeDelete["updated_at"], retained["updated_at"])
println("DB EVIDENCE soft-delete before=$beforeDelete after=$retained")
assertEquals(emptyList<JsonNode>(), success(send(HttpMethod.GET, "?placement=CONTENT_RECOMMENDATION")).toList())
Mockito.verifyNoInteractions(s3Uploader)
listOf(
send(HttpMethod.DELETE, "/$id"),
send(HttpMethod.PUT, "/$id", baseFields() + ("isActive" to true)),
send(HttpMethod.PUT, "/$id", rawRequest = "{")
).forEach { response ->
evidence("inactive mutation", response)
assertStatus(response, HttpStatus.NOT_FOUND)
assertEquals(listOf(retained), storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
}
@ParameterizedTest
@ValueSource(ints = [0, 1, 2, 3, 4, 5, 6, 7])
@DisplayName("주소 8개 조합 중 모두 빈 조합만 거부하고 나머지는 생성·수정한다")
fun shouldRejectAllEmptyLinksAndAllowSevenNonEmptyCombinations(mask: Int) {
val fields = baseFields() + mapOf(
"webUrl" to if (mask and 1 != 0) " $WEB " else " \t ",
"androidDeepLink" to if (mask and 2 != 0) "sampleapp://promo?id=a%2Fb" else null,
"iosDeepLink" to if (mask and 4 != 0) "other-app:promo" else ""
)
if (mask == 0) {
assertRejectedWithoutMutation(fields)
return
}
val created = success(send(HttpMethod.POST, fields = fields))
val id = created["id"].asLong()
val stored = reload(id)
assertEquals(if (mask and 1 != 0) WEB else null, stored.webUrl)
assertEquals(if (mask and 2 != 0) "sampleapp://promo?id=a%2Fb" else null, stored.androidDeepLink)
assertEquals(if (mask and 4 != 0) "other-app:promo" else null, stored.iosDeepLink)
assertEquals(created, success(send(HttpMethod.PUT, "/$id", fields, image = null)))
}
@Test
@DisplayName("raw JSON의 필수 기본 필드·실제 타입·객체·정확한 placement를 업로드 전에 검증한다")
fun shouldRejectMissingBaseFieldsAndNonStringLinksBeforeUpload() {
baseFields().keys.forEach { missing -> assertRejectedWithoutMutation(baseFields() - missing) }
listOf("webUrl", "androidDeepLink", "iosDeepLink").forEach { field ->
listOf(1, true, emptyList<String>(), emptyMap<String, String>()).forEach { value ->
assertRejectedWithoutMutation(baseFields() + (field to value))
}
}
listOf(null, 0, true, emptyList<String>(), "", "home_recommendation", " HOME_RECOMMENDATION", "OTHER").forEach {
assertRejectedWithoutMutation(baseFields() + ("placement" to it))
}
listOf("{", "[]", "null", "42", "\"string\"", "{}", "{} {}").forEach { raw ->
assertRejectedWithoutMutation(rawRequest = raw)
}
assertRejectedWithoutMutation(baseFields() + mapOf("webUrl" to null, "androidDeepLink" to null, "iosDeepLink" to null))
}
@Test
@DisplayName("위험 scheme·상대 URI·실제 host 없는 웹 주소와 문법 오류를 생성·수정 전에 거부한다")
fun shouldRejectUnsafeOrMalformedUrisBeforeUploadOrPersistence() {
evidence("invalid URI", send(HttpMethod.POST, fields = baseFields() + ("webUrl" to "javascript:alert(1)")))
val unsafe = listOf("javascript:alert(1)", "JaVaScRiPt:alert(1)", "DATA:text/plain,x", "file:///tmp/a", "CoNtEnT://a")
listOf("webUrl", "androidDeepLink", "iosDeepLink").forEach { field ->
(unsafe + listOf("relative/path", "//example.test/path", "app://bad path", "app://bad%zz")).forEach { value ->
assertRejectedWithoutMutation(baseFields() + (field to value))
}
}
listOf("ftp://example.test", "https:/missing-host", "http:///path", "https://", "https://bad_host/a").forEach {
assertRejectedWithoutMutation(baseFields() + ("webUrl" to it))
}
}
@Test
@DisplayName("주소는 trim 외 대소문자·인코딩·순서·fragment·긴 문자열을 변경하지 않는다")
fun shouldPreserveLinkUriAfterTrimOnly() {
val links = mapOf(
"webUrl" to "HTTPS://Example.TEST/a%2fb/../x?b=2&a=%2F+%20#Frag",
"androidDeepLink" to "Intent://promo/Item?id=a%2Fb#Intent;scheme=sample;end",
"iosDeepLink" to "new-custom:Item?value=${"a".repeat(512)}%2f"
)
val created = success(send(HttpMethod.POST, fields = baseFields() + links.mapValues { " \t${it.value}\n " }))
val stored = reload(created["id"].asLong())
links.forEach { (field, value) -> assertEquals(value, created[field].asText()) }
assertEquals(links["webUrl"], stored.webUrl)
assertEquals(links["androidDeepLink"], stored.androidDeepLink)
assertEquals(links["iosDeepLink"], stored.iosDeepLink)
}
@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",
"1970-01-01 09:01, 2038-01-19 12:13, 1970-01-01T00:01:00, 2038-01-19T03:13:59",
"OMITTED, 1970-01-01 09:00, , 1970-01-01T00:00:59",
"2038-01-19 12:14, OMITTED, 2038-01-19T03:14:00, ",
"1988-05-08 01:30, 1988-05-08 03:30, 1988-05-07T16:30:00, 1988-05-07T17:30:59",
"1988-10-09 02:30, 1988-10-09 02:30, 1988-10-08T16:30:00, 1988-10-08T16:30:59"
)
@DisplayName("H2 TIMESTAMP(0) 재조회 후 UTC 초 경계와 HTTP KST 분 문자열이 정확히 왕복한다")
fun shouldRoundTripAdminDateStringsExactlyAfterDatabaseReload(
start: String,
end: String,
utcStart: String?,
utcEnd: String?
) {
val response = send(HttpMethod.POST, fields = baseFields() + dates(start, end))
if (start == "2026-10-27 23:59") evidence("POST dates", response)
val created = success(response)
val stored = reload(created["id"].asLong())
assertEquals(utcStart?.let(LocalDateTime::parse), stored.startDate)
assertEquals(utcEnd?.let(LocalDateTime::parse), stored.endDate)
assertDate(created, "startDate", start)
assertDate(created, "endDate", end)
assertEquals(listOf(created), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList())
}
@ParameterizedTest
@ValueSource(strings = ["startDate", "endDate"])
@DisplayName("존재하지 않는 KST DST gap 분은 POST·PUT 모두 업로드와 DB 변경 전에 거부한다")
fun shouldRejectKstDstGapBeforeUploadOrPersistence(field: String) {
assertRejectedWithoutMutation(
baseFields() + mapOf("startDate" to null, "endDate" to null) + (field to "1988-05-08 02:30")
)
}
@Test
@DisplayName("날짜의 실제 JSON 타입·엄격한 형식·달력·DB TIMESTAMP 범위·역전을 업로드 전에 거부한다")
fun shouldRejectInvalidDateJsonTypesFormatsAndRangesBeforeUpload() {
val invalid = listOf(
2026, true, emptyList<String>(), emptyMap<String, String>(), "", " ", "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-10-27 10:00Z", "2026-10-27 10:00+09:00",
"2026-1-01 10:00", "+02026-10-27 10:00", " 2026-10-27 10:00", "2026-10-27 10:00 ",
"0000-01-01 00:00", "9999-12-31 23:59", "1969-12-31 23:59", "2039-01-01 00:00"
)
listOf("startDate", "endDate").forEach { field ->
invalid.forEach { value -> assertRejectedWithoutMutation(baseFields() + (field to value)) }
}
assertRejectedWithoutMutation(baseFields() + dates("1970-01-01 09:00", "OMITTED"))
assertRejectedWithoutMutation(baseFields() + dates("OMITTED", "2038-01-19 12:14"))
assertRejectedWithoutMutation(baseFields() + dates("2026-10-29 00:00", "2026-10-28 23:59"))
}
@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("수정 생략은 경계를 보존하고 null은 개별 해제하며 이미지·주소는 유지한다")
fun shouldUpdateDatesWithOmittedPreservedAndNullCleared(start: String, end: String, wantStart: String, wantEnd: String) {
val id = createScheduled()
val before = reload(id)
Mockito.clearInvocations(s3Uploader)
val updated = success(send(HttpMethod.PUT, "/$id", baseFields() + dates(start, end), image = null))
assertDate(updated, "startDate", wantStart)
assertDate(updated, "endDate", wantEnd)
val stored = reload(id)
assertEquals(expectedUtc(wantStart, 0), stored.startDate)
assertEquals(expectedUtc(wantEnd, 59), stored.endDate)
assertEquals(before.imagePath, stored.imagePath)
assertEquals(before.webUrl, stored.webUrl)
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(start: String, end: String) {
val id = createScheduled()
val before = storedRows()
Mockito.clearInvocations(s3Uploader)
assertStatus(send(HttpMethod.PUT, "/$id", baseFields() + dates(start, end)), HttpStatus.BAD_REQUEST)
assertEquals(before, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
@Test
@DisplayName("관리자 목록은 빈 배열·placement 격리·미래·만료·무기한과 20개 초과 id 순서를 보장한다")
fun shouldListAllPeriodsInIdOrderWithoutTruncation() {
assertEquals(emptyList<JsonNode>(), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList())
val ids = (0 until 23).map { index ->
val period = when (index % 3) {
0 -> dates("2030-01-01 00:00", "2030-01-02 23:59")
1 -> dates("2020-01-01 00:00", "2020-01-02 23:59")
else -> emptyMap()
}
success(send(HttpMethod.POST, fields = baseFields() + period))["id"].asLong()
}
val other = success(send(HttpMethod.POST, fields = baseFields() + ("placement" to "CONTENT_RECOMMENDATION")))
assertEquals(ids, success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).map { it["id"].asLong() })
assertEquals(listOf(other), success(send(HttpMethod.GET, "?placement=CONTENT_RECOMMENDATION")).toList())
listOf("", "?placement=", "?placement=OTHER", "?placement=home_recommendation").forEach {
assertStatus(send(HttpMethod.GET, it), HttpStatus.BAD_REQUEST)
}
}
@Test
@DisplayName("빈 파일·위장 이미지 교체를 거부하고 업로드 실패 시 기존 행과 이미지 경로를 보존한다")
fun shouldKeepExistingImageWhenReplacementUploadFails() {
val id = createScheduled()
val before = storedRows()
Mockito.clearInvocations(s3Uploader)
listOf(byteArrayOf(), "not an image".toByteArray()).forEach { bytes ->
assertStatus(send(HttpMethod.POST, image = bytes), HttpStatus.BAD_REQUEST)
assertStatus(send(HttpMethod.PUT, "/$id", image = bytes), HttpStatus.BAD_REQUEST)
assertEquals(before, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
Mockito.doThrow(IllegalStateException("Upload failed")).`when`(s3Uploader)
.upload(anyInputStream(), Mockito.anyString(), Mockito.anyString(), Mockito.isNull())
val response = send(HttpMethod.PUT, "/$id", baseFields() + ("webUrl" to "https://changed.test"))
assertEquals(HttpStatus.OK, response.statusCode)
assertFalse(objectMapper.readTree(response.body)["success"].asBoolean())
assertEquals(before, storedRows())
assertFalse(requireNotNull(response.body).contains("Upload failed"))
}
@Test
@DisplayName("없는 id의 수정·삭제는 404이며 이미지 업로드나 행 변경이 없다")
fun shouldRejectMissingIdWithoutMutation() {
createScheduled()
val before = storedRows()
Mockito.clearInvocations(s3Uploader)
listOf(HttpMethod.PUT, HttpMethod.DELETE).forEach { method ->
val response = send(method, "/${Long.MAX_VALUE}")
evidence("missing $method", response)
assertStatus(response, HttpStatus.NOT_FOUND)
}
assertEquals(before, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
@Test
@DisplayName("익명 401과 모든 비관리자 역할의 CRUD 거부는 업로드·DB 변경 없이 기존 정책을 따른다")
fun shouldRejectAnonymousAndNonAdminBeforeUpload() {
val id = createScheduled()
val before = storedRows()
Mockito.clearInvocations(s3Uploader)
(listOf<String?>(null) + MemberRole.values().filter { it != MemberRole.ADMIN }.map { tokenFor(it) }).forEach { token ->
listOf(
HttpMethod.GET to "?placement=HOME_RECOMMENDATION",
HttpMethod.POST to "",
HttpMethod.PUT to "/$id",
HttpMethod.DELETE to "/$id"
).forEach { (method, suffix) ->
val response = send(method, suffix, token = token)
if (token == null) {
assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode)
} else {
assertEquals(HttpStatus.OK, response.statusCode)
val body = objectMapper.readTree(response.body)
assertFalse(body["success"].asBoolean())
assertEquals("You do not have permission.", body["message"].asText())
}
if (method == HttpMethod.DELETE) evidence(if (token == null) "anonymous" else "non-admin", response)
}
}
assertEquals(before, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
@Test
@DisplayName("필수 multipart part 누락·잘못된 multipart는 기존 framework 오류 envelope를 반환하며 변경하지 않는다")
fun shouldCharacterizeMissingImageAndMalformedMultipart() {
val responses = listOf(
send(HttpMethod.POST, image = null),
send(HttpMethod.POST, includeRequest = false),
restTemplate.exchange(
url(),
HttpMethod.POST,
HttpEntity("not a multipart body", headers(adminToken).apply { contentType = MediaType.MULTIPART_FORM_DATA }),
String::class.java
)
)
responses.forEach { response ->
evidence("framework multipart", response)
assertEquals(HttpStatus.OK, response.statusCode)
val body = objectMapper.readTree(response.body)
assertFalse(body["success"].asBoolean())
assertTrue(body["data"].isNull)
}
assertEquals(0L, rowCount())
Mockito.verifyNoInteractions(s3Uploader)
}
private fun assertRejectedWithoutMutation(fields: Map<String, Any?> = baseFields(), rawRequest: String? = null) {
if (rowCount() == 0L) createScheduled()
val id = jdbcTemplate.queryForObject("select min(id) from sub_banner", Long::class.java)
val before = storedRows()
Mockito.clearInvocations(s3Uploader)
listOf(HttpMethod.POST to "", HttpMethod.PUT to "/$id").forEach { (method, suffix) ->
val response = send(method, suffix, fields, rawRequest = rawRequest)
assertStatus(response, HttpStatus.BAD_REQUEST)
assertFalse(requireNotNull(response.body).contains("javascript", ignoreCase = true))
assertEquals(before, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
}
private fun createScheduled(): Long = success(
send(HttpMethod.POST, fields = baseFields() + dates("2026-10-28 00:00", "2026-10-29 23:59"))
)["id"].asLong()
private fun send(
method: HttpMethod,
suffix: String = "",
fields: Map<String, Any?> = baseFields(),
image: ByteArray? = PNG,
token: String? = adminToken,
rawRequest: String? = null,
includeRequest: Boolean = true
): ResponseEntity<String> {
val headers = headers(token)
val body = if (method == HttpMethod.POST || method == HttpMethod.PUT) {
headers.contentType = MediaType.MULTIPART_FORM_DATA
LinkedMultiValueMap<String, Any>().apply {
if (includeRequest) {
add(
"request",
HttpEntity(
rawRequest ?: objectMapper.writeValueAsString(fields),
HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON }
)
)
}
if (image != null) {
add(
"image",
HttpEntity(
object : ByteArrayResource(image) {
override fun getFilename(): String = "client-name.png"
},
HttpHeaders().apply { contentType = MediaType.IMAGE_PNG }
)
)
}
}
} else {
null
}
return restTemplate.exchange(url() + suffix, method, HttpEntity(body, headers), String::class.java)
}
private fun headers(token: String?): HttpHeaders = HttpHeaders().apply {
token?.let { setBearerAuth(it) }
set(HttpHeaders.ACCEPT_LANGUAGE, "en")
accept = listOf(MediaType.APPLICATION_JSON)
}
private fun success(response: ResponseEntity<String>, allowNullData: Boolean = false): JsonNode {
assertEquals(HttpStatus.OK, response.statusCode, response.body)
val body = objectMapper.readTree(response.body)
assertEquals(setOf("success", "message", "data", "errorProperty"), body.fieldNames().asSequence().toSet())
assertTrue(body["success"].asBoolean(), response.body)
assertTrue(body["message"].isNull)
assertTrue(body["errorProperty"].isNull)
if (!allowNullData) assertFalse(body["data"].isNull, "Successful create/update/list must return data")
return body["data"]
}
private fun assertAdminFields(data: JsonNode) {
assertEquals(
setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate"),
data.fieldNames().asSequence().toSet()
)
}
private fun assertStatus(response: ResponseEntity<String>, status: HttpStatus) {
assertEquals(status, response.statusCode, response.body)
val body = objectMapper.readTree(response.body)
assertEquals(status.value(), body["status"].asInt())
assertFalse(body.has("trace"))
assertFalse(body.has("exception"))
}
private fun reload(id: Long): SubBanner = requireNotNull(
transactionTemplate.execute {
entityManager.flush()
entityManager.clear()
repository.findById(id)
}
)
private fun storedRows(): List<Map<String, Any>> = jdbcTemplate.queryForList("select * from sub_banner order by id")
private fun rowCount(): Long = requireNotNull(
jdbcTemplate.queryForObject("select count(*) from sub_banner", Long::class.java)
)
private fun tokenFor(role: MemberRole): String {
val member = memberRepository.saveAndFlush(
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "test", role = role)
)
val id = requireNotNull(member.id)
memberIds.add(id)
return tokenProvider.createToken(
UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${role.name}"))),
id
)
}
private fun assertDate(data: JsonNode, field: String, value: String) {
if (value == "NULL" || value == "OMITTED") assertTrue(data[field].isNull) else assertEquals(value, data[field].asText())
}
private fun dates(start: String, end: String): Map<String, Any?> = mapOf("startDate" to start, "endDate" to end)
.filterValues { it != "OMITTED" }
.mapValues { if (it.value == "NULL") null else it.value }
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 evidence(label: String, response: ResponseEntity<String>) {
println("HTTP EVIDENCE $label ${response.statusCodeValue}: ${response.body}")
}
private fun url(): String = "http://127.0.0.1:$port/admin/sub-banners"
private fun anyInputStream(): InputStream = Mockito.any(InputStream::class.java) ?: byteArrayOf().inputStream()
private fun baseFields(): Map<String, Any?> = mapOf(
"placement" to "HOME_RECOMMENDATION",
"webUrl" to WEB,
"androidDeepLink" to null,
"iosDeepLink" to null
)
companion object {
private const val WEB = "https://example.test/promo?x=a%2Fb"
private val PNG = Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a0foAAAAASUVORK5CYII="
)
}
}