feat(subbanner): 관리자 CRUD와 이미지·기간 검증을 추가한다
This commit is contained in:
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.subbanner.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
|
||||||
|
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
|
||||||
|
import kr.co.vividnext.sodalive.v2.subbanner.dto.AdminSubBannerResponse
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/sub-banners")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminSubBannerController(
|
||||||
|
private val service: SubBannerService,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String
|
||||||
|
) {
|
||||||
|
@GetMapping
|
||||||
|
fun list(@RequestParam(required = false) placement: String?): ApiResponse<List<AdminSubBannerResponse>> {
|
||||||
|
val selected = SubBannerPlacement.values().firstOrNull { it.name == placement }
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid sub-banner placement")
|
||||||
|
return ApiResponse.ok(service.findAllByPlacement(selected).map { AdminSubBannerResponse.from(it, cloudFrontHost) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||||
|
fun create(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") request: String
|
||||||
|
): ApiResponse<AdminSubBannerResponse> = ApiResponse.ok(
|
||||||
|
AdminSubBannerResponse.from(service.create(image, request), cloudFrontHost)
|
||||||
|
)
|
||||||
|
|
||||||
|
@PutMapping("/{id}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||||
|
fun update(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@RequestPart("image", required = false) image: MultipartFile?,
|
||||||
|
@RequestPart("request") request: String
|
||||||
|
): ApiResponse<AdminSubBannerResponse> = ApiResponse.ok(
|
||||||
|
AdminSubBannerResponse.from(service.update(id, image, request), cloudFrontHost)
|
||||||
|
)
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
fun delete(@PathVariable id: Long): ApiResponse<Any> {
|
||||||
|
service.delete(id)
|
||||||
|
return ApiResponse.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.subbanner.application
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||||
|
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.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import kr.co.vividnext.sodalive.utils.validateImage
|
||||||
|
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
|
||||||
|
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
|
||||||
|
import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerRequest
|
||||||
|
import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URISyntaxException
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
|
import java.time.format.ResolverStyle
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class SubBannerService(
|
||||||
|
private val repository: SubBannerRepository,
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
@Value("\${cloud.aws.s3.bucket}") private val bucket: String,
|
||||||
|
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
|
||||||
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findAllByPlacement(placement: SubBannerPlacement): List<SubBanner> = repository.findAllByPlacement(placement)
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findVisibleByPlacement(placement: SubBannerPlacement): List<SubBanner> {
|
||||||
|
val now = nowProvider().truncatedTo(ChronoUnit.SECONDS)
|
||||||
|
return repository.findVisibleByPlacement(placement, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun create(image: MultipartFile, request: String): SubBanner {
|
||||||
|
val fields = readFields(request)
|
||||||
|
val input = readRequest(fields)
|
||||||
|
val startDate = parseDate(input.startDate, 0)
|
||||||
|
val endDate = parseDate(input.endDate, 59)
|
||||||
|
validateRange(startDate, endDate)
|
||||||
|
val imagePath = uploadImage(image)
|
||||||
|
return repository.save(
|
||||||
|
SubBanner(
|
||||||
|
placement = input.placement,
|
||||||
|
imagePath = imagePath,
|
||||||
|
webUrl = input.webUrl,
|
||||||
|
androidDeepLink = input.androidDeepLink,
|
||||||
|
iosDeepLink = input.iosDeepLink,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun update(id: Long, image: MultipartFile?, request: String): SubBanner {
|
||||||
|
val existing = findExisting(id)
|
||||||
|
val fields = readFields(request)
|
||||||
|
val input = readRequest(fields)
|
||||||
|
val startDate = if (fields.has("startDate")) parseDate(input.startDate, 0) else existing.startDate
|
||||||
|
val endDate = if (fields.has("endDate")) parseDate(input.endDate, 59) else existing.endDate
|
||||||
|
validateRange(startDate, endDate)
|
||||||
|
val imagePath = image?.let { uploadImage(it) } ?: existing.imagePath
|
||||||
|
return repository.save(
|
||||||
|
existing.copy(
|
||||||
|
placement = input.placement,
|
||||||
|
imagePath = imagePath,
|
||||||
|
webUrl = input.webUrl,
|
||||||
|
androidDeepLink = input.androidDeepLink,
|
||||||
|
iosDeepLink = input.iosDeepLink,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun delete(id: Long) {
|
||||||
|
findExisting(id)
|
||||||
|
repository.deleteById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findExisting(id: Long): SubBanner = repository.findById(id)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Sub-banner not found")
|
||||||
|
|
||||||
|
private fun readFields(request: String): JsonNode {
|
||||||
|
val fields = try {
|
||||||
|
objectMapper.readerFor(JsonNode::class.java)
|
||||||
|
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
||||||
|
.readValue<JsonNode>(request)
|
||||||
|
} catch (_: JsonProcessingException) {
|
||||||
|
invalidRequest()
|
||||||
|
}
|
||||||
|
if (!fields.isObject || !fields.path("placement").isTextual ||
|
||||||
|
SubBannerPlacement.values().none { it.name == fields["placement"].textValue() }
|
||||||
|
) {
|
||||||
|
invalidRequest()
|
||||||
|
}
|
||||||
|
listOf("webUrl", "androidDeepLink", "iosDeepLink").forEach { field ->
|
||||||
|
val value = fields[field]
|
||||||
|
if (value == null || (!value.isNull && !value.isTextual)) invalidRequest()
|
||||||
|
}
|
||||||
|
listOf("startDate", "endDate").forEach { field ->
|
||||||
|
val value = fields[field]
|
||||||
|
if (value != null && !value.isNull && !value.isTextual) invalidRequest()
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readRequest(fields: JsonNode): SubBannerRequest {
|
||||||
|
val input = objectMapper.treeToValue(fields, SubBannerRequest::class.java)
|
||||||
|
val normalized = input.copy(
|
||||||
|
webUrl = input.webUrl?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
|
androidDeepLink = input.androidDeepLink?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
|
iosDeepLink = input.iosDeepLink?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
)
|
||||||
|
if (listOf(normalized.webUrl, normalized.androidDeepLink, normalized.iosDeepLink).all { it == null }) invalidRequest()
|
||||||
|
validateUri(normalized.webUrl, web = true)
|
||||||
|
validateUri(normalized.androidDeepLink, web = false)
|
||||||
|
validateUri(normalized.iosDeepLink, web = false)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateUri(value: String?, web: Boolean) {
|
||||||
|
if (value == null) return
|
||||||
|
val uri = try {
|
||||||
|
URI(value)
|
||||||
|
} catch (_: URISyntaxException) {
|
||||||
|
invalidRequest()
|
||||||
|
}
|
||||||
|
if (!uri.isAbsolute || listOf("javascript", "data", "file", "content").any { it.equals(uri.scheme, ignoreCase = true) }) {
|
||||||
|
invalidRequest()
|
||||||
|
}
|
||||||
|
if (web && ((!uri.scheme.equals("http", true) && !uri.scheme.equals("https", true)) || uri.host.isNullOrBlank())) {
|
||||||
|
invalidRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDate(value: String?, second: Int): LocalDateTime? {
|
||||||
|
if (value == null) return null
|
||||||
|
if (value.length != 16) invalidRequest()
|
||||||
|
val utc = try {
|
||||||
|
val local = LocalDateTime.parse(value, DATE_FORMAT).withSecond(second)
|
||||||
|
val zone = ZoneId.of("Asia/Seoul")
|
||||||
|
if (zone.rules.getValidOffsets(local).isEmpty()) invalidRequest()
|
||||||
|
local.atZone(zone)
|
||||||
|
.withZoneSameInstant(ZoneOffset.UTC)
|
||||||
|
.toLocalDateTime()
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
invalidRequest()
|
||||||
|
}
|
||||||
|
if (utc < MIN_TIMESTAMP || utc > MAX_TIMESTAMP) invalidRequest()
|
||||||
|
return utc
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateRange(startDate: LocalDateTime?, endDate: LocalDateTime?) {
|
||||||
|
if (startDate != null && endDate != null && startDate > endDate) invalidRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uploadImage(image: MultipartFile): String {
|
||||||
|
if (image.isEmpty) invalidRequest()
|
||||||
|
try {
|
||||||
|
validateImage(image, gifAllowed = true)
|
||||||
|
} catch (_: SodaException) {
|
||||||
|
invalidRequest()
|
||||||
|
}
|
||||||
|
return image.inputStream.use { s3Uploader.upload(it, bucket, "sub_banner/${generateFileName()}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidRequest(): Nothing = throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid sub-banner request")
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DATE_FORMAT = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm").withResolverStyle(ResolverStyle.STRICT)
|
||||||
|
private val MIN_TIMESTAMP = LocalDateTime.of(1970, 1, 1, 0, 0, 1)
|
||||||
|
private val MAX_TIMESTAMP = LocalDateTime.of(2038, 1, 19, 3, 14, 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.subbanner.dto
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
|
||||||
|
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
data class AdminSubBannerResponse(
|
||||||
|
val id: Long,
|
||||||
|
val placement: SubBannerPlacement,
|
||||||
|
val imageUrl: String,
|
||||||
|
val webUrl: String?,
|
||||||
|
val androidDeepLink: String?,
|
||||||
|
val iosDeepLink: String?,
|
||||||
|
val startDate: String?,
|
||||||
|
val endDate: String?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(banner: SubBanner, cloudFrontHost: String): AdminSubBannerResponse = AdminSubBannerResponse(
|
||||||
|
id = requireNotNull(banner.id),
|
||||||
|
placement = banner.placement,
|
||||||
|
imageUrl = "$cloudFrontHost/${banner.imagePath}",
|
||||||
|
webUrl = banner.webUrl,
|
||||||
|
androidDeepLink = banner.androidDeepLink,
|
||||||
|
iosDeepLink = banner.iosDeepLink,
|
||||||
|
startDate = formatDate(banner.startDate),
|
||||||
|
endDate = formatDate(banner.endDate)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun formatDate(value: LocalDateTime?): String? = value?.atZone(ZoneOffset.UTC)
|
||||||
|
?.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
||||||
|
?.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.subbanner.dto
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
|
||||||
|
|
||||||
|
data class SubBannerRequest(
|
||||||
|
val placement: SubBannerPlacement,
|
||||||
|
val webUrl: String?,
|
||||||
|
val androidDeepLink: String?,
|
||||||
|
val iosDeepLink: String?,
|
||||||
|
val startDate: String? = null,
|
||||||
|
val endDate: String? = null
|
||||||
|
)
|
||||||
+596
@@ -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="
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user