feat(notice): 예약 관리와 공개 시점 기반 조회를 구현한다
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
package kr.co.vividnext.sodalive.notice
|
package kr.co.vividnext.sodalive.notice
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
data class CreateNoticeRequest(
|
data class CreateNoticeRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val content: String
|
val content: String,
|
||||||
|
val publishAt: String? = null
|
||||||
) {
|
) {
|
||||||
fun toEntity(): ServiceNotice {
|
fun toEntity(publishAt: LocalDateTime? = null): ServiceNotice {
|
||||||
return ServiceNotice(title, content)
|
return ServiceNotice(title, content, publishAt = publishAt, isScheduled = publishAt != null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package kr.co.vividnext.sodalive.notice
|
package kr.co.vividnext.sodalive.notice
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
@@ -14,20 +17,31 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/notice")
|
@RequestMapping("/notice")
|
||||||
class ServiceNoticeController(private val service: ServiceNoticeService) {
|
class ServiceNoticeController(
|
||||||
|
private val service: ServiceNoticeService,
|
||||||
|
private val objectMapper: ObjectMapper
|
||||||
|
) {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
fun createNotice(@RequestBody request: CreateNoticeRequest) = ApiResponse.ok(
|
fun createNotice(@RequestBody fields: JsonNode): ApiResponse<Long> {
|
||||||
service.save(request),
|
val publishAt = fields["publishAt"]
|
||||||
"등록되었습니다."
|
if (!fields.isObject || (publishAt != null && !publishAt.isTextual && !publishAt.isNull)) {
|
||||||
)
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
val request = objectMapper.treeToValue(fields, CreateNoticeRequest::class.java)
|
||||||
|
return ApiResponse.ok(service.save(request), "등록되었습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping
|
@PutMapping
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
fun updateNotice(@RequestBody request: UpdateNoticeRequest) = ApiResponse.ok(
|
fun updateNotice(@RequestBody fields: JsonNode): ApiResponse<Unit> {
|
||||||
service.update(request),
|
val publishAt = fields["publishAt"]
|
||||||
"수정되었습니다."
|
if (!fields.isObject || (publishAt != null && !publishAt.isTextual && !publishAt.isNull)) {
|
||||||
)
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
val request = objectMapper.treeToValue(fields, UpdateNoticeRequest::class.java)
|
||||||
|
return ApiResponse.ok(service.update(request, publishAtPresent = fields.has("publishAt")), "수정되었습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
|||||||
@@ -2,32 +2,52 @@ package kr.co.vividnext.sodalive.notice
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
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
|
@Service
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
class ServiceNoticeService(private val repository: ServiceServiceNoticeRepository) {
|
class ServiceNoticeService(
|
||||||
|
private val repository: ServiceServiceNoticeRepository,
|
||||||
|
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
|
||||||
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun save(request: CreateNoticeRequest): Long {
|
fun save(request: CreateNoticeRequest): Long {
|
||||||
if (request.title.isBlank()) throw SodaException(messageKey = "notice.error.title_required")
|
if (request.title.isBlank()) throw SodaException(messageKey = "notice.error.title_required")
|
||||||
if (request.content.isBlank()) throw SodaException(messageKey = "notice.error.content_required")
|
if (request.content.isBlank()) throw SodaException(messageKey = "notice.error.content_required")
|
||||||
|
|
||||||
val notice = request.toEntity()
|
val publishAt = request.publishAt?.let { parsePublishAt(it, nowProvider()) }
|
||||||
|
val notice = request.toEntity(publishAt)
|
||||||
return repository.save(notice).id!!
|
return repository.save(notice).id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun update(request: UpdateNoticeRequest) {
|
fun update(request: UpdateNoticeRequest, publishAtPresent: Boolean = false) {
|
||||||
if (request.id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
|
if (request.id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
if (request.title.isNullOrBlank() && request.content.isNullOrBlank()) {
|
if (request.title.isNullOrBlank() && request.content.isNullOrBlank() && !publishAtPresent) {
|
||||||
throw SodaException(messageKey = "notice.error.update_required")
|
throw SodaException(messageKey = "notice.error.update_required")
|
||||||
}
|
}
|
||||||
|
|
||||||
val notice = repository.findByIdOrNull(request.id)
|
val notice = repository.findByIdForUpdate(request.id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
|
if (publishAtPresent) {
|
||||||
|
val now = nowProvider()
|
||||||
|
val currentPublishAt = notice.publishAt
|
||||||
|
if (!notice.isActive || !notice.isScheduled || currentPublishAt == null || currentPublishAt <= now) {
|
||||||
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
notice.publishAt = request.publishAt?.let { parsePublishAt(it, now) } ?: now.truncatedTo(ChronoUnit.SECONDS)
|
||||||
|
notice.isScheduled = request.publishAt != null
|
||||||
|
}
|
||||||
|
|
||||||
if (!request.title.isNullOrBlank()) notice.title = request.title
|
if (!request.title.isNullOrBlank()) notice.title = request.title
|
||||||
if (!request.content.isNullOrBlank()) notice.content = request.content
|
if (!request.content.isNullOrBlank()) notice.content = request.content
|
||||||
}
|
}
|
||||||
@@ -35,20 +55,45 @@ class ServiceNoticeService(private val repository: ServiceServiceNoticeRepositor
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun delete(id: Long) {
|
fun delete(id: Long) {
|
||||||
if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
|
if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
val notice = repository.findByIdOrNull(id)
|
val notice = repository.findByIdForUpdate(id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
notice.isActive = false
|
notice.isActive = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getNoticeList(pageable: Pageable, timezone: String): GetNoticeResponse {
|
fun getNoticeList(pageable: Pageable, timezone: String): GetNoticeResponse {
|
||||||
val totalCount = repository.getNoticeTotalCount()
|
val now = nowProvider()
|
||||||
val noticeList = repository.getNoticeList(pageable)
|
val totalCount = repository.getNoticeTotalCount(now)
|
||||||
|
val noticeList = repository.getNoticeList(pageable, now)
|
||||||
|
|
||||||
return GetNoticeResponse(totalCount, noticeList)
|
return GetNoticeResponse(totalCount, noticeList)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLatestNotice(): NoticeItem? {
|
fun getLatestNotice(): NoticeItem? {
|
||||||
return repository.getLatestNotice()
|
val now = nowProvider()
|
||||||
|
return repository.getLatestNotice(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parsePublishAt(value: String, now: LocalDateTime): LocalDateTime {
|
||||||
|
if (value.length != 19) throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
val utc = try {
|
||||||
|
LocalDateTime.parse(value, PUBLISH_AT_FORMAT)
|
||||||
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
|
.withZoneSameInstant(ZoneOffset.UTC)
|
||||||
|
.toLocalDateTime()
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
if (utc < MIN_TIMESTAMP || utc > MAX_TIMESTAMP || utc <= now) {
|
||||||
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
return utc
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val PUBLISH_AT_FORMAT = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss")
|
||||||
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +1,121 @@
|
|||||||
package kr.co.vividnext.sodalive.notice
|
package kr.co.vividnext.sodalive.notice
|
||||||
|
|
||||||
import com.querydsl.core.types.dsl.DateTimePath
|
import com.querydsl.core.types.Expression
|
||||||
|
import com.querydsl.core.types.dsl.BooleanExpression
|
||||||
|
import com.querydsl.core.types.dsl.CaseBuilder
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
import com.querydsl.core.types.dsl.StringTemplate
|
import com.querydsl.core.types.dsl.StringTemplate
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.notice.QServiceNotice.serviceNotice
|
import kr.co.vividnext.sodalive.notice.QServiceNotice.serviceNotice
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.notice.dto.AdminNoticeItem
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.notice.dto.QAdminNoticeItem
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Lock
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.data.repository.query.Param
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.LockModeType
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface ServiceServiceNoticeRepository : JpaRepository<ServiceNotice, Long>, ServiceNoticeQueryRepository
|
interface ServiceServiceNoticeRepository : JpaRepository<ServiceNotice, Long>, ServiceNoticeQueryRepository {
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("select notice from ServiceNotice notice where notice.id = :id")
|
||||||
|
fun findByIdForUpdate(@Param("id") id: Long): ServiceNotice?
|
||||||
|
}
|
||||||
|
|
||||||
interface ServiceNoticeQueryRepository {
|
interface ServiceNoticeQueryRepository {
|
||||||
fun getNoticeTotalCount(): Int
|
fun getNoticeTotalCount(now: LocalDateTime): Int
|
||||||
fun getNoticeList(pageable: Pageable): List<NoticeItem>
|
fun getNoticeList(pageable: Pageable, now: LocalDateTime): List<NoticeItem>
|
||||||
fun getLatestNotice(): NoticeItem?
|
fun getLatestNotice(now: LocalDateTime): NoticeItem?
|
||||||
|
fun getAdminNoticeTotalCount(): Int
|
||||||
|
fun getAdminNoticeList(pageable: Pageable): List<AdminNoticeItem>
|
||||||
}
|
}
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class ServiceNoticeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : ServiceNoticeQueryRepository {
|
class ServiceNoticeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : ServiceNoticeQueryRepository {
|
||||||
override fun getNoticeTotalCount(): Int {
|
override fun getNoticeTotalCount(now: LocalDateTime): Int {
|
||||||
return queryFactory
|
return requireNotNull(
|
||||||
.select(serviceNotice.id)
|
queryFactory.select(serviceNotice.id.count())
|
||||||
.from(serviceNotice)
|
.from(serviceNotice)
|
||||||
.where(serviceNotice.isActive.isTrue)
|
.where(isPublic(now))
|
||||||
.fetch()
|
.fetchOne()
|
||||||
.size
|
).toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getNoticeList(pageable: Pageable): List<NoticeItem> {
|
override fun getNoticeList(pageable: Pageable, now: LocalDateTime): List<NoticeItem> {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QNoticeItem(
|
QNoticeItem(
|
||||||
serviceNotice.id,
|
serviceNotice.id,
|
||||||
serviceNotice.title,
|
serviceNotice.title,
|
||||||
serviceNotice.content,
|
serviceNotice.content,
|
||||||
getFormattedDate(serviceNotice.createdAt)
|
getFormattedDate(
|
||||||
|
CaseBuilder().`when`(serviceNotice.isScheduled.isTrue)
|
||||||
|
.then(serviceNotice.publishAt).otherwise(serviceNotice.createdAt),
|
||||||
|
"%Y-%m-%d"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(serviceNotice)
|
||||||
|
.where(isPublic(now))
|
||||||
|
.offset(pageable.offset)
|
||||||
|
.limit(pageable.pageSize.toLong())
|
||||||
|
.orderBy(serviceNotice.publishAt.coalesce(serviceNotice.createdAt).desc(), serviceNotice.id.desc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLatestNotice(now: LocalDateTime): NoticeItem? {
|
||||||
|
return getNoticeList(PageRequest.of(0, 1), now).firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAdminNoticeTotalCount(): Int {
|
||||||
|
return requireNotNull(
|
||||||
|
queryFactory.select(serviceNotice.id.count())
|
||||||
|
.from(serviceNotice)
|
||||||
|
.where(serviceNotice.isActive.isTrue)
|
||||||
|
.fetchOne()
|
||||||
|
).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAdminNoticeList(pageable: Pageable): List<AdminNoticeItem> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QAdminNoticeItem(
|
||||||
|
serviceNotice.id,
|
||||||
|
serviceNotice.title,
|
||||||
|
serviceNotice.content,
|
||||||
|
serviceNotice.isActive,
|
||||||
|
serviceNotice.isScheduled,
|
||||||
|
getFormattedDate(serviceNotice.publishAt, "%Y-%m-%d %H:%i:%s"),
|
||||||
|
getFormattedDate(serviceNotice.createdAt, "%Y-%m-%d %H:%i:%s")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(serviceNotice)
|
.from(serviceNotice)
|
||||||
.where(serviceNotice.isActive.isTrue)
|
.where(serviceNotice.isActive.isTrue)
|
||||||
.offset(pageable.offset)
|
.offset(pageable.offset)
|
||||||
.limit(pageable.pageSize.toLong())
|
.limit(pageable.pageSize.toLong())
|
||||||
.orderBy(serviceNotice.id.desc())
|
.orderBy(serviceNotice.publishAt.coalesce(serviceNotice.createdAt).desc(), serviceNotice.id.desc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLatestNotice(): NoticeItem? {
|
private fun isPublic(now: LocalDateTime): BooleanExpression {
|
||||||
return queryFactory
|
return serviceNotice.isActive.isTrue.and(serviceNotice.publishAt.isNull.or(serviceNotice.publishAt.loe(now)))
|
||||||
.select(
|
|
||||||
QNoticeItem(
|
|
||||||
serviceNotice.id,
|
|
||||||
serviceNotice.title,
|
|
||||||
serviceNotice.content,
|
|
||||||
getFormattedDate(serviceNotice.createdAt)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.from(serviceNotice)
|
|
||||||
.where(serviceNotice.isActive.isTrue)
|
|
||||||
.orderBy(serviceNotice.id.desc())
|
|
||||||
.fetchFirst()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
|
private fun getFormattedDate(dateTime: Expression<LocalDateTime>, format: String): StringTemplate {
|
||||||
return Expressions.stringTemplate(
|
return Expressions.stringTemplate(
|
||||||
"DATE_FORMAT({0}, {1})",
|
"DATE_FORMAT({0}, {1})",
|
||||||
Expressions.dateTimeTemplate(
|
Expressions.dateTimeTemplate(
|
||||||
LocalDateTime::class.java,
|
LocalDateTime::class.java,
|
||||||
"CONVERT_TZ({0},{1},{2})",
|
"CONVERT_TZ({0},{1},{2})",
|
||||||
dateTimePath,
|
dateTime,
|
||||||
"UTC",
|
"UTC",
|
||||||
"Asia/Seoul"
|
"Asia/Seoul"
|
||||||
),
|
),
|
||||||
"%Y-%m-%d"
|
format
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ package kr.co.vividnext.sodalive.notice
|
|||||||
data class UpdateNoticeRequest(
|
data class UpdateNoticeRequest(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
val content: String? = null
|
val content: String? = null,
|
||||||
|
val publishAt: String? = null
|
||||||
)
|
)
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.notice.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.notice.application.AdminNoticeService
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.notice.dto.AdminNoticeResponse
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v2/admin/notices")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminNoticeController(private val service: AdminNoticeService) {
|
||||||
|
@GetMapping
|
||||||
|
fun getNotices(pageable: Pageable): ApiResponse<AdminNoticeResponse> = ApiResponse.ok(service.getNoticeList(pageable))
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.notice.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.notice.dto.AdminNoticeResponse
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class AdminNoticeService(private val repository: ServiceServiceNoticeRepository) {
|
||||||
|
fun getNoticeList(pageable: Pageable): AdminNoticeResponse {
|
||||||
|
val totalCount = repository.getAdminNoticeTotalCount()
|
||||||
|
val noticeList = repository.getAdminNoticeList(pageable)
|
||||||
|
return AdminNoticeResponse(totalCount, noticeList)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.notice.dto
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
|
||||||
|
data class AdminNoticeItem @QueryProjection constructor(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val content: String,
|
||||||
|
val isActive: Boolean,
|
||||||
|
val isScheduled: Boolean,
|
||||||
|
val publishAt: String?,
|
||||||
|
val createdAt: String
|
||||||
|
)
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.notice.dto
|
||||||
|
|
||||||
|
data class AdminNoticeResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val noticeList: List<AdminNoticeItem>
|
||||||
|
)
|
||||||
+10
-2
@@ -5,9 +5,17 @@ import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository
|
|||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
class LatestNoticeService(private val repository: ServiceServiceNoticeRepository) {
|
class LatestNoticeService(
|
||||||
fun getLatestNotices(): List<NoticeItem> = repository.getNoticeList(PageRequest.of(0, 3))
|
private val repository: ServiceServiceNoticeRepository,
|
||||||
|
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
|
||||||
|
) {
|
||||||
|
fun getLatestNotices(): List<NoticeItem> {
|
||||||
|
val now = nowProvider()
|
||||||
|
return repository.getNoticeList(PageRequest.of(0, 3), now)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content.main.tab.home
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
|
import kr.co.vividnext.sodalive.notice.NoticeItem
|
||||||
|
import kr.co.vividnext.sodalive.notice.ServiceNotice
|
||||||
|
import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository
|
||||||
|
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.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration
|
||||||
|
import org.springframework.boot.test.web.client.TestRestTemplate
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest
|
||||||
|
import org.springframework.test.annotation.DirtiesContext
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes
|
||||||
|
import java.sql.Timestamp
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||||
|
properties = [
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:notice-home-integration;" +
|
||||||
|
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@Import(AudioContentMainTabHomeServiceIntegrationTest.TimeConfiguration::class)
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
|
class AudioContentMainTabHomeServiceIntegrationTest @Autowired constructor(
|
||||||
|
private val service: AudioContentMainTabHomeService,
|
||||||
|
private val repository: ServiceServiceNoticeRepository,
|
||||||
|
private val jdbcTemplate: JdbcTemplate,
|
||||||
|
private val restTemplate: TestRestTemplate,
|
||||||
|
private val objectMapper: ObjectMapper
|
||||||
|
) {
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
repository.deleteAllInBatch()
|
||||||
|
jdbcTemplate.execute("ALTER TABLE service_notice ALTER COLUMN publish_at TIMESTAMP(0)")
|
||||||
|
jdbcTemplate.execute(
|
||||||
|
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
|
||||||
|
)
|
||||||
|
jdbcTemplate.execute(
|
||||||
|
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun cleanUp() {
|
||||||
|
repository.deleteAllInBatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 홈 조립과 HTTP latestNotice는 미공개 예약을 제외한 최신 공개 공지다")
|
||||||
|
fun shouldDelegateHomeLatestNoticeToRealFilteredLookup() {
|
||||||
|
val id = requireNotNull(repository.saveAndFlush(ServiceNotice("공개 공지", "홈 전체 본문")).id)
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE service_notice SET created_at = ? WHERE id = ?",
|
||||||
|
Timestamp.valueOf("2026-10-29 15:00:00"),
|
||||||
|
id
|
||||||
|
)
|
||||||
|
repository.saveAndFlush(
|
||||||
|
ServiceNotice("미공개", "노출 금지", publishAt = LocalDateTime.of(2026, 11, 1, 0, 0, 0), isScheduled = true)
|
||||||
|
)
|
||||||
|
repository.saveAndFlush(ServiceNotice("비활성", "노출 금지", isActive = false))
|
||||||
|
|
||||||
|
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest()))
|
||||||
|
try {
|
||||||
|
val result = service.fetchData(false, ContentType.ALL, null)
|
||||||
|
assertEquals(NoticeItem(id, "공개 공지", "홈 전체 본문", "2026-10-30"), result.latestNotice)
|
||||||
|
} finally {
|
||||||
|
RequestContextHolder.resetRequestAttributes()
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = restTemplate.getForEntity("/v2/audio-content/main/home", String::class.java)
|
||||||
|
println("HTTP GET /v2/audio-content/main/home -> ${response.statusCodeValue} ${response.body}")
|
||||||
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
|
val body = objectMapper.readTree(response.body)
|
||||||
|
assertEquals(true, body["success"].asBoolean())
|
||||||
|
assertEquals(
|
||||||
|
objectMapper.readTree(
|
||||||
|
"""{"id":$id,"title":"공개 공지","content":"홈 전체 본문","date":"2026-10-30"}"""
|
||||||
|
),
|
||||||
|
body["data"]["latestNotice"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
class TimeConfiguration {
|
||||||
|
@Bean
|
||||||
|
fun noticeNowProvider(): () -> LocalDateTime = { LocalDateTime.of(2026, 10, 30, 0, 0, 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
package kr.co.vividnext.sodalive.notice
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
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.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.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.test.annotation.DirtiesContext
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
|
import java.sql.Timestamp
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.Future
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
import javax.persistence.LockModeType
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
properties = [
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:notice-concurrency;" +
|
||||||
|
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;" +
|
||||||
|
"DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;LOCK_TIMEOUT=15000"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@Import(ServiceNoticeConcurrencyIntegrationTest.TimeConfiguration::class)
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
|
class ServiceNoticeConcurrencyIntegrationTest @Autowired constructor(
|
||||||
|
private val service: ServiceNoticeService,
|
||||||
|
private val repository: ServiceServiceNoticeRepository,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val transactionManager: PlatformTransactionManager,
|
||||||
|
private val jdbcTemplate: JdbcTemplate,
|
||||||
|
private val noticeTime: AtomicReference<LocalDateTime>
|
||||||
|
) {
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
noticeTime.set(LocalDateTime.of(2026, 10, 30, 0, 0, 0, 123456789))
|
||||||
|
repository.deleteAllInBatch()
|
||||||
|
jdbcTemplate.execute("ALTER TABLE service_notice ALTER COLUMN publish_at TIMESTAMP(0)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun cleanUp() {
|
||||||
|
repository.deleteAllInBatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("row lock 대기 중 공개 경계를 넘으면 잠금 뒤 fresh now로 재검사하여 시각·제목 변경을 거부한다")
|
||||||
|
fun shouldRejectUpdateWhenLockWaitCrossesPublishAt() {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
|
||||||
|
val failure = runWithBlockedWrite(
|
||||||
|
id,
|
||||||
|
first = {},
|
||||||
|
second = { service.update(UpdateNoticeRequest(id, title = "반영 금지", publishAt = NEXT_SCHEDULE), true) },
|
||||||
|
whileBlocked = { noticeTime.set(LocalDateTime.of(2026, 11, 1, 0, 0, 0)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
assertInvalidRequest(failure)
|
||||||
|
val stored = reload(id)
|
||||||
|
assertEquals(before, stored)
|
||||||
|
assertEquals(before.createdAt, stored.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("즉시 전환이 먼저 잠금을 얻으면 뒤의 재예약은 최신 즉시 상태를 읽고 거부한다")
|
||||||
|
fun shouldSerializeImmediateConversionAndReschedule() {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
|
||||||
|
val failure = runWithBlockedWrite(
|
||||||
|
id,
|
||||||
|
first = { service.update(UpdateNoticeRequest(id), true) },
|
||||||
|
second = { service.update(UpdateNoticeRequest(id, publishAt = NEXT_SCHEDULE), true) }
|
||||||
|
)
|
||||||
|
|
||||||
|
assertInvalidRequest(failure)
|
||||||
|
val stored = reload(id)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 10, 30, 0, 0, 0), stored.publishAt)
|
||||||
|
assertEquals(false, stored.isScheduled)
|
||||||
|
assertEquals(true, stored.isActive)
|
||||||
|
assertEquals(before.createdAt, stored.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(booleans = [true, false])
|
||||||
|
@DisplayName("제목과 재예약 동시 수정은 어느 순서에서도 최신 제목과 예약 시각을 함께 보존한다")
|
||||||
|
fun shouldPreserveLatestScheduleWhenTitleUpdateRaces(scheduleFirst: Boolean) {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
val reschedule = { service.update(UpdateNoticeRequest(id, publishAt = NEXT_SCHEDULE), true) }
|
||||||
|
val titleUpdate = { service.update(UpdateNoticeRequest(id, title = "최신 제목")) }
|
||||||
|
|
||||||
|
assertNull(
|
||||||
|
runWithBlockedWrite(
|
||||||
|
id,
|
||||||
|
first = if (scheduleFirst) reschedule else titleUpdate,
|
||||||
|
second = if (scheduleFirst) titleUpdate else reschedule
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val stored = reload(id)
|
||||||
|
assertEquals("최신 제목", stored.title)
|
||||||
|
assertEquals(before.content, stored.content)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 11, 2, 0, 0, 0), stored.publishAt)
|
||||||
|
assertEquals(true, stored.isScheduled)
|
||||||
|
assertEquals(true, stored.isActive)
|
||||||
|
assertEquals(before.createdAt, stored.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource("true,true", "true,false", "false,true", "false,false")
|
||||||
|
@DisplayName("삭제와 즉시 전환·재예약은 순서대로 처리하여 삭제를 되돌리거나 최신 예약을 덮지 않는다")
|
||||||
|
fun shouldSerializeDeleteAndScheduleChanges(deleteFirst: Boolean, immediate: Boolean) {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
val scheduleUpdate = {
|
||||||
|
service.update(UpdateNoticeRequest(id, publishAt = if (immediate) null else NEXT_SCHEDULE), true)
|
||||||
|
}
|
||||||
|
val delete = { service.delete(id) }
|
||||||
|
|
||||||
|
val failure = runWithBlockedWrite(
|
||||||
|
id,
|
||||||
|
first = if (deleteFirst) delete else scheduleUpdate,
|
||||||
|
second = if (deleteFirst) scheduleUpdate else delete
|
||||||
|
)
|
||||||
|
|
||||||
|
if (deleteFirst) assertInvalidRequest(failure) else assertNull(failure)
|
||||||
|
val stored = reload(id)
|
||||||
|
assertEquals(false, stored.isActive)
|
||||||
|
assertEquals(
|
||||||
|
when {
|
||||||
|
deleteFirst -> LocalDateTime.of(2026, 11, 1, 0, 0, 0)
|
||||||
|
immediate -> LocalDateTime.of(2026, 10, 30, 0, 0, 0)
|
||||||
|
else -> LocalDateTime.of(2026, 11, 2, 0, 0, 0)
|
||||||
|
},
|
||||||
|
stored.publishAt
|
||||||
|
)
|
||||||
|
assertEquals(deleteFirst || !immediate, stored.isScheduled)
|
||||||
|
assertEquals(before.createdAt, stored.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("삭제 후 대기하던 기존 제목 수정은 허용하되 stale row로 삭제 상태를 되돌리지 않는다")
|
||||||
|
fun shouldPreserveDeletionWhenTitleUpdateWaits() {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
|
||||||
|
assertNull(
|
||||||
|
runWithBlockedWrite(
|
||||||
|
id,
|
||||||
|
first = { service.delete(id) },
|
||||||
|
second = { service.update(UpdateNoticeRequest(id, title = "삭제 후 제목")) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val stored = reload(id)
|
||||||
|
assertEquals(false, stored.isActive)
|
||||||
|
assertEquals("삭제 후 제목", stored.title)
|
||||||
|
assertEquals(before.publishAt, stored.publishAt)
|
||||||
|
assertEquals(before.isScheduled, stored.isScheduled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runWithBlockedWrite(
|
||||||
|
id: Long,
|
||||||
|
first: () -> Unit,
|
||||||
|
second: () -> Unit,
|
||||||
|
whileBlocked: () -> Unit = {}
|
||||||
|
): Throwable? {
|
||||||
|
val locked = CountDownLatch(1)
|
||||||
|
val release = CountDownLatch(1)
|
||||||
|
val executor = Executors.newFixedThreadPool(2)
|
||||||
|
val futures = mutableListOf<Future<*>>()
|
||||||
|
try {
|
||||||
|
val holder = executor.submit {
|
||||||
|
TransactionTemplate(transactionManager).executeWithoutResult {
|
||||||
|
entityManager.find(ServiceNotice::class.java, id, LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
first()
|
||||||
|
entityManager.flush()
|
||||||
|
locked.countDown()
|
||||||
|
check(release.await(10, TimeUnit.SECONDS)) { "lock holder was not released" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
futures.add(holder)
|
||||||
|
assertTrue(locked.await(5, TimeUnit.SECONDS), "first transaction did not acquire the row lock")
|
||||||
|
val waiter = executor.submit<Throwable?> { runCatching { second() }.exceptionOrNull() }
|
||||||
|
futures.add(waiter)
|
||||||
|
assertTrue(awaitBlockedSelect(waiter), "second write did not wait on SELECT FOR UPDATE")
|
||||||
|
whileBlocked()
|
||||||
|
release.countDown()
|
||||||
|
holder.get(5, TimeUnit.SECONDS)
|
||||||
|
return waiter.get(5, TimeUnit.SECONDS)
|
||||||
|
} finally {
|
||||||
|
release.countDown()
|
||||||
|
futures.forEach { it.cancel(true) }
|
||||||
|
executor.shutdownNow()
|
||||||
|
assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS), "concurrency executor did not terminate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun awaitBlockedSelect(waiter: Future<*>): Boolean {
|
||||||
|
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||||
|
while (System.nanoTime() < deadline && !waiter.isDone) {
|
||||||
|
val blocked = jdbcTemplate.queryForList(
|
||||||
|
"SELECT EXECUTING_STATEMENT FROM INFORMATION_SCHEMA.SESSIONS " +
|
||||||
|
"WHERE BLOCKER_ID IS NOT NULL AND LOWER(EXECUTING_STATEMENT) LIKE '%service_notice%for update%'",
|
||||||
|
String::class.java
|
||||||
|
)
|
||||||
|
if (blocked.isNotEmpty()) {
|
||||||
|
println("DB blocked SELECT FOR UPDATE: ${blocked.single()}")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
Thread.yield()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduledNotice(): ServiceNotice {
|
||||||
|
val notice = repository.saveAndFlush(
|
||||||
|
ServiceNotice("예약 제목", "예약 본문", publishAt = LocalDateTime.of(2026, 11, 1, 0, 0, 0), isScheduled = true)
|
||||||
|
)
|
||||||
|
val id = requireNotNull(notice.id)
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE service_notice SET created_at = ? WHERE id = ?",
|
||||||
|
Timestamp.valueOf("2026-10-29 14:59:59.123456"),
|
||||||
|
id
|
||||||
|
)
|
||||||
|
return reload(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reload(id: Long): ServiceNotice {
|
||||||
|
return requireNotNull(
|
||||||
|
TransactionTemplate(transactionManager).execute {
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
repository.findById(id).orElseThrow().also {
|
||||||
|
println(
|
||||||
|
"DB concurrency reload id=$id title=${it.title} active=${it.isActive} " +
|
||||||
|
"scheduled=${it.isScheduled} publishAt=${it.publishAt} createdAt=${it.createdAt}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertInvalidRequest(failure: Throwable?) {
|
||||||
|
assertTrue(failure is SodaException, "expected SodaException, got $failure")
|
||||||
|
assertEquals("common.error.invalid_request", (failure as SodaException).messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
class TimeConfiguration {
|
||||||
|
@Bean
|
||||||
|
fun noticeTime(): AtomicReference<LocalDateTime> = AtomicReference(LocalDateTime.of(2026, 10, 30, 0, 0, 0))
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun noticeNowProvider(noticeTime: AtomicReference<LocalDateTime>): () -> LocalDateTime = { noticeTime.get() }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val NEXT_SCHEDULE = "2026-11-02 09:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package kr.co.vividnext.sodalive.notice
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
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.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.test.annotation.DirtiesContext
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.sql.DriverManager
|
||||||
|
import java.sql.SQLException
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
properties = [
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:notice-query-repository;" +
|
||||||
|
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@Import(ServiceNoticeQueryRepositoryTest.TimeConfiguration::class)
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
|
@Transactional
|
||||||
|
class ServiceNoticeQueryRepositoryTest @Autowired constructor(
|
||||||
|
private val service: ServiceNoticeService,
|
||||||
|
private val repository: ServiceServiceNoticeRepository,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val jdbcTemplate: JdbcTemplate
|
||||||
|
) {
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
jdbcTemplate.execute("ALTER TABLE service_notice ALTER COLUMN publish_at TIMESTAMP(0)")
|
||||||
|
jdbcTemplate.execute(
|
||||||
|
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
|
||||||
|
)
|
||||||
|
jdbcTemplate.execute(
|
||||||
|
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("필터는 페이지와 latest limit 전에 적용되며 count는 공개 활성 건수다")
|
||||||
|
fun shouldApplyPublicFilterBeforeLatestLimit() {
|
||||||
|
val eligible = (1..4).map { day -> saveNotice("2026-10-${20 + day}T00:00:00") }
|
||||||
|
repeat(5) { saveNotice("2026-10-29T00:00:00", "2026-11-01T00:00:00") }
|
||||||
|
saveNotice("2026-10-29T00:00:00", isActive = false)
|
||||||
|
saveNotice("2026-10-29T00:00:00", "2026-10-29T00:00:00", isActive = false)
|
||||||
|
|
||||||
|
val first = service.getNoticeList(PageRequest.of(0, 3), "UTC")
|
||||||
|
val second = service.getNoticeList(PageRequest.of(1, 3), "UTC")
|
||||||
|
assertEquals(4, first.totalCount)
|
||||||
|
assertEquals(eligible.reversed().take(3), first.noticeList.map { it.id })
|
||||||
|
assertEquals(4, second.totalCount)
|
||||||
|
assertEquals(listOf(eligible.first()), second.noticeList.map { it.id })
|
||||||
|
assertEquals(eligible.last(), service.getLatestNotice()?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("동일 공개 시각은 등록 시각이 아니라 id 역순으로 정렬한다")
|
||||||
|
fun shouldOrderEqualPublishTimesByIdDescending() {
|
||||||
|
val first = saveNotice("2026-10-29T00:00:00", "2026-10-29T12:00:00")
|
||||||
|
val second = saveNotice("2026-10-28T00:00:00", "2026-10-29T12:00:00")
|
||||||
|
val immediate = saveNotice("2026-10-29T12:00:00")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(immediate, second, first),
|
||||||
|
service.getNoticeList(PageRequest.of(0, 20), "Asia/Seoul").noticeList.map { it.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("count와 list는 서비스가 한 번 캡처한 동일 now로 경계와 총 건수를 판정한다")
|
||||||
|
fun shouldCaptureNowOnceForCountAndList() {
|
||||||
|
val boundary = LocalDateTime.of(2026, 10, 30, 0, 0, 0)
|
||||||
|
val visible = saveNotice("2026-10-29T00:00:00", boundary.toString())
|
||||||
|
saveNotice("2026-10-29T00:00:00", boundary.plusSeconds(1).toString())
|
||||||
|
var calls = 0
|
||||||
|
val advancingService = ServiceNoticeService(repository) { boundary.plusSeconds(calls++.toLong()) }
|
||||||
|
|
||||||
|
val result = advancingService.getNoticeList(PageRequest.of(0, 20), "UTC")
|
||||||
|
|
||||||
|
assertEquals(1, calls)
|
||||||
|
assertEquals(1, result.totalCount)
|
||||||
|
assertEquals(listOf(visible), result.noticeList.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveNotice(createdAt: String, publishAt: String? = null, isActive: Boolean = true): Long {
|
||||||
|
val notice = repository.saveAndFlush(
|
||||||
|
ServiceNotice(
|
||||||
|
"공지",
|
||||||
|
"본문",
|
||||||
|
isActive,
|
||||||
|
publishAt = publishAt?.let(LocalDateTime::parse),
|
||||||
|
isScheduled = publishAt != null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entityManager.createNativeQuery("UPDATE service_notice SET created_at = :createdAt WHERE id = :id")
|
||||||
|
.setParameter("createdAt", LocalDateTime.parse(createdAt))
|
||||||
|
.setParameter("id", notice.id)
|
||||||
|
.executeUpdate()
|
||||||
|
entityManager.clear()
|
||||||
|
return requireNotNull(notice.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("KST 예약 생성은 TIMESTAMP(0) flush·clear·재조회 후 UTC 값을 정확히 보존한다")
|
||||||
|
fun shouldPersistAndReloadScheduledPublishAt() {
|
||||||
|
val id = service.save(CreateNoticeRequest("예약 공지", "본문", "2026-11-01 09:00:00"))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val stored = repository.findById(id).orElseThrow()
|
||||||
|
println("DB reload id=$id publishAt=${stored.publishAt} isScheduled=${stored.isScheduled}")
|
||||||
|
assertEquals(LocalDateTime.of(2026, 11, 1, 0, 0, 0), stored.publishAt)
|
||||||
|
assertEquals(0, requireNotNull(stored.publishAt).nano)
|
||||||
|
assertEquals(true, stored.isScheduled)
|
||||||
|
assertNotNull(stored.createdAt)
|
||||||
|
assertEquals(
|
||||||
|
0,
|
||||||
|
jdbcTemplate.queryForObject(
|
||||||
|
"SELECT DATETIME_PRECISION FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||||
|
"WHERE TABLE_NAME = 'service_notice' AND COLUMN_NAME = 'publish_at'",
|
||||||
|
Int::class.java
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("기존 생성은 DB 왕복 후에도 예약 시각 null과 예약 상태 false를 보존한다")
|
||||||
|
fun shouldPersistAndReloadImmediateNotice() {
|
||||||
|
val id = service.save(CreateNoticeRequest("즉시 공지", "본문"))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val stored = repository.findById(id).orElseThrow()
|
||||||
|
assertNull(stored.publishAt)
|
||||||
|
assertEquals(false, stored.isScheduled)
|
||||||
|
assertNotNull(stored.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("작성한 additive DDL은 기존 row와 created_at을 바꾸지 않고 nullable 시각·false 기본값을 추가한다")
|
||||||
|
fun shouldApplyAdditiveDdlWithoutChangingExistingRows() {
|
||||||
|
DriverManager.getConnection(
|
||||||
|
"jdbc:h2:mem:notice-publication-ddl;MODE=MySQL;DATABASE_TO_UPPER=false"
|
||||||
|
).use { connection ->
|
||||||
|
connection.createStatement().use { statement ->
|
||||||
|
statement.execute("CREATE TABLE service_notice (id BIGINT PRIMARY KEY, created_at TIMESTAMP(6))")
|
||||||
|
statement.execute("INSERT INTO service_notice VALUES (1, TIMESTAMP '2026-10-29 23:59:59.123456')")
|
||||||
|
statement.execute(
|
||||||
|
Files.readString(Path.of("docs/20261030_공지사항예약공개/alter-service-notice-publication.sql"))
|
||||||
|
)
|
||||||
|
statement.executeQuery("SELECT publish_at, is_scheduled, created_at FROM service_notice").use { rows ->
|
||||||
|
assertTrue(rows.next())
|
||||||
|
assertNull(rows.getTimestamp("publish_at"))
|
||||||
|
assertEquals(false, rows.getBoolean("is_scheduled"))
|
||||||
|
assertEquals(
|
||||||
|
LocalDateTime.of(2026, 10, 29, 23, 59, 59, 123456000),
|
||||||
|
rows.getTimestamp("created_at").toLocalDateTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
connection.metaData.getColumns(null, null, "service_notice", "publish_at").use { columns ->
|
||||||
|
assertTrue(columns.next())
|
||||||
|
assertEquals("YES", columns.getString("IS_NULLABLE"))
|
||||||
|
assertEquals("예약 공개 시각(UTC)", columns.getString("REMARKS"))
|
||||||
|
}
|
||||||
|
connection.metaData.getColumns(null, null, "service_notice", "is_scheduled").use { columns ->
|
||||||
|
assertTrue(columns.next())
|
||||||
|
assertEquals("NO", columns.getString("IS_NULLABLE"))
|
||||||
|
assertEquals("예약 공개 여부", columns.getString("REMARKS"))
|
||||||
|
}
|
||||||
|
assertThrows(SQLException::class.java) {
|
||||||
|
statement.executeUpdate("UPDATE service_notice SET is_scheduled = NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
class TimeConfiguration {
|
||||||
|
@Bean
|
||||||
|
fun noticeNowProvider(): () -> LocalDateTime = { LocalDateTime.of(2026, 10, 30, 0, 0, 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ class H2MysqlDateFunctions {
|
|||||||
.replace("%W", "EEEE")
|
.replace("%W", "EEEE")
|
||||||
.replace("%h", "hh")
|
.replace("%h", "hh")
|
||||||
.replace("%i", "mm")
|
.replace("%i", "mm")
|
||||||
|
.replace("%s", "ss")
|
||||||
.replace("%p", "a")
|
.replace("%p", "a")
|
||||||
|
|
||||||
return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern))
|
return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern))
|
||||||
|
|||||||
+695
@@ -0,0 +1,695 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.notice
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
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.notice.ServiceNotice
|
||||||
|
import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository
|
||||||
|
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.assertNotNull
|
||||||
|
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.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration
|
||||||
|
import org.springframework.boot.test.web.client.TestRestTemplate
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
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.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.PlatformTransactionManager
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
|
import java.sql.Timestamp
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||||
|
properties = [
|
||||||
|
"jwt.secret=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZg==",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:admin-notice-http;" +
|
||||||
|
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@Import(AdminNoticeControllerIntegrationTest.TimeConfiguration::class)
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
|
class AdminNoticeControllerIntegrationTest @Autowired constructor(
|
||||||
|
private val restTemplate: TestRestTemplate,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val repository: ServiceServiceNoticeRepository,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val tokenProvider: TokenProvider,
|
||||||
|
private val tokenRepository: MemberTokenRepository,
|
||||||
|
private val jdbcTemplate: JdbcTemplate,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val transactionManager: PlatformTransactionManager,
|
||||||
|
private val noticeTime: AtomicReference<LocalDateTime>
|
||||||
|
) {
|
||||||
|
private lateinit var adminToken: String
|
||||||
|
private val memberIds = mutableListOf<Long>()
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
noticeTime.set(LocalDateTime.of(2026, 10, 30, 0, 0, 0))
|
||||||
|
repository.deleteAllInBatch()
|
||||||
|
jdbcTemplate.execute("ALTER TABLE service_notice ALTER COLUMN publish_at TIMESTAMP(0)")
|
||||||
|
jdbcTemplate.execute(
|
||||||
|
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
|
||||||
|
)
|
||||||
|
jdbcTemplate.execute(
|
||||||
|
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
|
||||||
|
)
|
||||||
|
adminToken = tokenFor(MemberRole.ADMIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["", ",\"publishAt\":null"])
|
||||||
|
@DisplayName("생략과 null 생성은 기존 ID 응답과 원본 등록 시각을 유지하는 즉시 공지다")
|
||||||
|
fun shouldCreateImmediateNoticeWhenPublishAtIsOmittedOrNull(publishAtField: String) {
|
||||||
|
val body = postNotice("""{"title":"공지","content":"본문"$publishAtField}""")
|
||||||
|
val notice = repository.findAll().single()
|
||||||
|
|
||||||
|
assertCreated(body, requireNotNull(notice.id))
|
||||||
|
assertEquals("공지", notice.title)
|
||||||
|
assertEquals("본문", notice.content)
|
||||||
|
assertTrue(notice.isActive)
|
||||||
|
assertEquals(false, notice.isScheduled)
|
||||||
|
assertNull(notice.publishAt)
|
||||||
|
assertNotNull(notice.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"2026-11-01 09:00:00, 2026-11-01T00:00:00",
|
||||||
|
"2026-10-30 09:00:01, 2026-10-30T00:00:01",
|
||||||
|
"2028-02-29 00:00:00, 2028-02-28T15:00:00",
|
||||||
|
"2038-01-19 12:14:07, 2038-01-19T03:14:07"
|
||||||
|
)
|
||||||
|
@DisplayName("미래 KST 생성은 UTC 초 단위 예약과 기존 생성 ID 응답을 저장한다")
|
||||||
|
fun shouldCreateScheduledNoticeWithUtcPublishAt(publishAt: String, expectedUtc: String) {
|
||||||
|
val body = postNotice("""{"title":"예약 공지","content":"예약 본문","publishAt":"$publishAt"}""")
|
||||||
|
val notice = repository.findAll().single()
|
||||||
|
|
||||||
|
assertCreated(body, requireNotNull(notice.id))
|
||||||
|
assertEquals("예약 공지", notice.title)
|
||||||
|
assertEquals("예약 본문", notice.content)
|
||||||
|
assertTrue(notice.isActive)
|
||||||
|
assertEquals(true, notice.isScheduled)
|
||||||
|
assertEquals(LocalDateTime.parse(expectedUtc), notice.publishAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun cleanUp() {
|
||||||
|
repository.deleteAllInBatch()
|
||||||
|
memberIds.forEach { tokenRepository.deleteById(it) }
|
||||||
|
memberRepository.deleteAllById(memberIds)
|
||||||
|
memberIds.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(
|
||||||
|
strings = [
|
||||||
|
"42", "true", "{}", "[]", "[\"2026-11-01 09:00:00\"]",
|
||||||
|
"\"\"", "\"2026-02-30 09:00:00\"", "\"2026-11-31 09:00:00\"",
|
||||||
|
"\"2026-11-01T09:00:00\"", "\"2026-11-01 09:00\"", "\"2026-11-01 09:00:00.0\"",
|
||||||
|
"\"2026-11-01 09:00:00Z\"", "\" 2026-11-01 09:00:00\"", "\"2026-11-01 09:00:00 \"",
|
||||||
|
"\"2026-1-01 09:00:00\"", "\"2026-11-01 24:00:00\"", "\"2026-11-01 09:00:60\"",
|
||||||
|
"\"2026-10-30 09:00:00\"", "\"2026-10-30 08:59:59\"",
|
||||||
|
"\"1970-01-01 09:00:00\"", "\"2038-01-19 12:14:08\"", "\"9999-12-31 23:59:59\""
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@DisplayName("생성은 문자열 외 타입과 엄격한 형식·달력·미래·TIMESTAMP 범위 위반을 저장하지 않는다")
|
||||||
|
fun shouldRejectInvalidPublishAtWithoutPersistence(publishAtJson: String) {
|
||||||
|
val body = postNotice("""{"title":"공지","content":"본문","publishAt":$publishAtJson}""")
|
||||||
|
|
||||||
|
assertEquals(error("Invalid request."), body)
|
||||||
|
assertEquals(0L, repository.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["[]", "true", "42", "\"notice\"", "null"])
|
||||||
|
@DisplayName("생성 요청은 JSON object만 허용한다")
|
||||||
|
fun shouldRejectNonObjectCreateRequest(json: String) {
|
||||||
|
assertEquals(error("Invalid request."), postNotice(json))
|
||||||
|
assertEquals(0L, repository.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("생성은 기존 제목과 본문 공백 오류 envelope를 유지한다")
|
||||||
|
fun shouldPreserveBlankTitleAndContentErrors() {
|
||||||
|
assertEquals(error("Please enter a title."), postNotice("""{"title":" ","content":"본문"}"""))
|
||||||
|
assertEquals(error("Please enter content."), postNotice("""{"title":"공지","content":" "}"""))
|
||||||
|
assertEquals(0L, repository.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 보안 체인은 익명 생성을 401로 거부하고 일반 회원은 기존 권한 오류를 반환한다")
|
||||||
|
fun shouldPreserveAdminOnlyCreation() {
|
||||||
|
val json = """{"title":"공지","content":"본문","publishAt":"2026-11-01 09:00:00"}"""
|
||||||
|
val anonymous = restTemplate.postForEntity("/notice", request(json, null), String::class.java)
|
||||||
|
assertEquals(HttpStatus.UNAUTHORIZED, anonymous.statusCode)
|
||||||
|
assertEquals(error("You do not have permission."), postNotice(json, tokenFor(MemberRole.USER)))
|
||||||
|
assertEquals(0L, repository.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("publishAt 생략 수정은 제목·본문만 변경하고 예약 필드와 등록 시각을 보존한다")
|
||||||
|
fun shouldPreserveScheduleWhenPublishAtIsOmitted() {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
|
||||||
|
assertUpdated(putNotice("""{"id":$id,"title":"변경 제목","content":null}"""))
|
||||||
|
assertUpdated(putNotice("""{"id":$id,"title":" ","content":"변경 본문"}"""))
|
||||||
|
|
||||||
|
val stored = reload(id)
|
||||||
|
assertEquals("변경 제목", stored.title)
|
||||||
|
assertEquals("변경 본문", stored.content)
|
||||||
|
assertEquals(before.publishAt, stored.publishAt)
|
||||||
|
assertEquals(true, stored.isScheduled)
|
||||||
|
assertEquals(before.createdAt, stored.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("미공개 예약은 제목·본문과 미래 시각을 한 번에 변경한다")
|
||||||
|
fun shouldRescheduleUnpublishedNotice() {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
|
||||||
|
assertUpdated(
|
||||||
|
putNotice("""{"id":$id,"title":"변경 제목","content":"변경 본문","publishAt":"2026-11-02 10:00:00"}""")
|
||||||
|
)
|
||||||
|
|
||||||
|
val stored = reload(id)
|
||||||
|
assertEquals("변경 제목", stored.title)
|
||||||
|
assertEquals("변경 본문", stored.content)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 11, 2, 1, 0, 0), stored.publishAt)
|
||||||
|
assertEquals(true, stored.isScheduled)
|
||||||
|
assertEquals(before.createdAt, stored.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("제목·본문이 없는 예약 시각만의 수정도 유효하다")
|
||||||
|
fun shouldAllowScheduleOnlyUpdate() {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
|
||||||
|
assertUpdated(putNotice("""{"id":$id,"publishAt":"2026-11-02 09:00:00"}"""))
|
||||||
|
|
||||||
|
val stored = reload(id)
|
||||||
|
assertEquals(before.title, stored.title)
|
||||||
|
assertEquals(before.content, stored.content)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 11, 2, 0, 0, 0), stored.publishAt)
|
||||||
|
assertEquals(true, stored.isScheduled)
|
||||||
|
assertEquals(before.createdAt, stored.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"2026-10-30T00:00:00, 2026-10-30T00:00:00",
|
||||||
|
"2026-10-30T00:00:00.123456789, 2026-10-30T00:00:00",
|
||||||
|
"2026-10-30T00:00:00.999999999, 2026-10-30T00:00:00",
|
||||||
|
"2026-10-30T23:59:59, 2026-10-30T23:59:59",
|
||||||
|
"2026-10-30T23:59:59.123456789, 2026-10-30T23:59:59",
|
||||||
|
"2026-10-30T23:59:59.999999999, 2026-10-30T23:59:59"
|
||||||
|
)
|
||||||
|
@DisplayName("명시 null 즉시 전환은 원본 등록 시각을 보존하고 DB 왕복 시 초·날짜를 반올림하지 않는다")
|
||||||
|
fun shouldConvertScheduledNoticeToImmediateWhenPublishAtIsNull(now: String, expectedUtc: String) {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
noticeTime.set(LocalDateTime.parse(now))
|
||||||
|
|
||||||
|
assertUpdated(putNotice("""{"id":$id,"publishAt":null}"""))
|
||||||
|
|
||||||
|
val stored = reload(id)
|
||||||
|
assertEquals(LocalDateTime.parse(expectedUtc), stored.publishAt)
|
||||||
|
assertEquals(0, requireNotNull(stored.publishAt).nano)
|
||||||
|
assertEquals(false, stored.isScheduled)
|
||||||
|
assertEquals(before.title, stored.title)
|
||||||
|
assertEquals(before.content, stored.content)
|
||||||
|
assertEquals(before.createdAt, stored.createdAt)
|
||||||
|
assertEquals(
|
||||||
|
0,
|
||||||
|
jdbcTemplate.queryForObject(
|
||||||
|
"SELECT DATETIME_PRECISION FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||||
|
"WHERE TABLE_NAME = 'service_notice' AND COLUMN_NAME = 'publish_at'",
|
||||||
|
Int::class.java
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val body = getNotice("/api/v2/admin/notices?page=0&size=20")
|
||||||
|
val expectedKst = if (expectedUtc.endsWith("23:59:59")) "2026-10-31 08:59:59" else "2026-10-30 09:00:00"
|
||||||
|
assertEquals(
|
||||||
|
envelope(
|
||||||
|
"""{"totalCount":1,"noticeList":[
|
||||||
|
{"id":$id,"title":"예약 제목","content":"예약 본문","isActive":true,"isScheduled":false,
|
||||||
|
"publishAt":"$expectedKst","createdAt":"2026-10-29 23:59:59"}]}"""
|
||||||
|
),
|
||||||
|
body
|
||||||
|
)
|
||||||
|
val responseUtc = LocalDateTime.parse(body["data"]["noticeList"][0]["publishAt"].asText(), ADMIN_TIME_FORMAT)
|
||||||
|
.atZone(ZoneId.of("Asia/Seoul")).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()
|
||||||
|
assertEquals(stored.publishAt, responseUtc)
|
||||||
|
println("Precision input=$now DB=${stored.publishAt} adminKst=$expectedKst parsedUTC=$responseUtc")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["2026-11-01T00:00:00", "2026-11-01T00:00:00.000000001", "2026-11-02T00:00:00"])
|
||||||
|
@DisplayName("공개 경계 정각 이후에는 같은 시각 재지정과 null을 포함한 모든 시각 수정을 거부한다")
|
||||||
|
fun shouldRejectRescheduleAfterPublishBoundary(now: String) {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
noticeTime.set(LocalDateTime.parse(now))
|
||||||
|
|
||||||
|
listOf("null", "\"2026-11-01 09:00:00\"", "\"2026-11-03 09:00:00\"").forEach { publishAt ->
|
||||||
|
assertEquals(
|
||||||
|
error("Invalid request."),
|
||||||
|
putNotice("""{"id":$id,"title":"반영 금지","publishAt":$publishAt}""")
|
||||||
|
)
|
||||||
|
assertEquals(before, reload(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(booleans = [false, true])
|
||||||
|
@DisplayName("즉시 생성 또는 즉시 전환한 공지는 null과 미래 시각 모두 재예약할 수 없다")
|
||||||
|
fun shouldRejectScheduleChangesForImmediateNotice(converted: Boolean) {
|
||||||
|
val before = repository.saveAndFlush(
|
||||||
|
ServiceNotice("즉시 공지", "본문", publishAt = if (converted) noticeTime.get() else null)
|
||||||
|
)
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
|
||||||
|
listOf("null", "\"2026-11-03 09:00:00\"").forEach { publishAt ->
|
||||||
|
assertEquals(
|
||||||
|
error("Invalid request."),
|
||||||
|
putNotice("""{"id":$id,"title":"반영 금지","publishAt":$publishAt}""")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val stored = reload(id)
|
||||||
|
assertEquals(before.title, stored.title)
|
||||||
|
assertEquals(before.publishAt, stored.publishAt)
|
||||||
|
assertEquals(false, stored.isScheduled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("비활성 예약의 시각 변경은 거부하지만 기존 제목·본문 수정은 유지한다")
|
||||||
|
fun shouldRejectScheduleChangesForInactiveNoticeButKeepLegacyEdits() {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
jdbcTemplate.update("UPDATE service_notice SET is_active = false WHERE id = ?", id)
|
||||||
|
|
||||||
|
assertEquals(error("Invalid request."), putNotice("""{"id":$id,"title":"반영 금지","publishAt":null}"""))
|
||||||
|
assertEquals(
|
||||||
|
error("Invalid request."),
|
||||||
|
putNotice("""{"id":$id,"publishAt":"2026-11-03 09:00:00"}""")
|
||||||
|
)
|
||||||
|
assertUpdated(putNotice("""{"id":$id,"title":"비활성 제목"}"""))
|
||||||
|
val stored = reload(id)
|
||||||
|
assertEquals("비활성 제목", stored.title)
|
||||||
|
assertEquals(false, stored.isActive)
|
||||||
|
assertEquals(before.publishAt, stored.publishAt)
|
||||||
|
assertEquals(true, stored.isScheduled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("예약이 공개된 이후에도 제목·본문만 수정하는 기존 요청은 허용한다")
|
||||||
|
fun shouldAllowTitleAndContentEditsAfterPublication() {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
noticeTime.set(LocalDateTime.of(2026, 11, 1, 0, 0, 0))
|
||||||
|
|
||||||
|
assertUpdated(putNotice("""{"id":$id,"title":"공개 후 제목","content":"공개 후 본문"}"""))
|
||||||
|
val stored = reload(id)
|
||||||
|
assertEquals("공개 후 제목", stored.title)
|
||||||
|
assertEquals("공개 후 본문", stored.content)
|
||||||
|
assertEquals(before.publishAt, stored.publishAt)
|
||||||
|
assertEquals(before.isScheduled, stored.isScheduled)
|
||||||
|
assertEquals(before.createdAt, stored.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(
|
||||||
|
strings = [
|
||||||
|
"42", "true", "{}", "[]", "[\"2026-11-01 09:00:00\"]",
|
||||||
|
"\"\"", "\"2026-02-30 09:00:00\"", "\"2026-11-31 09:00:00\"",
|
||||||
|
"\"2026-11-01T09:00:00\"", "\"2026-11-01 09:00\"", "\"2026-11-01 09:00:00.0\"",
|
||||||
|
"\"2026-11-01 09:00:00Z\"", "\" 2026-11-01 09:00:00\"", "\"2026-11-01 09:00:00 \"",
|
||||||
|
"\"2026-1-01 09:00:00\"", "\"2026-11-01 24:00:00\"", "\"2026-11-01 09:00:60\"",
|
||||||
|
"\"2026-10-30 09:00:00\"", "\"2026-10-30 08:59:59\"",
|
||||||
|
"\"1970-01-01 09:00:00\"", "\"2038-01-19 12:14:08\"", "\"9999-12-31 23:59:59\""
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@DisplayName("잘못된 시각 수정은 타입·형식·달력·미래·범위를 거부하며 제목·본문도 부분 반영하지 않는다")
|
||||||
|
fun shouldRejectInvalidUpdatePublishAtWithoutPartialChanges(publishAtJson: String) {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
error("Invalid request."),
|
||||||
|
putNotice("""{"id":$id,"title":"반영 금지","content":"반영 금지","publishAt":$publishAtJson}""")
|
||||||
|
)
|
||||||
|
assertEquals(before, reload(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["[]", "true", "42", "\"notice\"", "null"])
|
||||||
|
@DisplayName("수정 요청은 JSON object만 허용한다")
|
||||||
|
fun shouldRejectNonObjectUpdateRequest(json: String) {
|
||||||
|
assertEquals(error("Invalid request."), putNotice(json))
|
||||||
|
assertEquals(0L, repository.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("잘못된 ID와 수정 필드가 없는 요청의 기존 business 오류를 보존한다")
|
||||||
|
fun shouldPreserveInvalidIdAndEmptyUpdateErrors() {
|
||||||
|
val id = requireNotNull(scheduledNotice().id)
|
||||||
|
listOf(0L, -1L, Long.MAX_VALUE).forEach { invalidId ->
|
||||||
|
assertEquals(error("Invalid request."), putNotice("""{"id":$invalidId,"title":"변경"}"""))
|
||||||
|
}
|
||||||
|
listOf("", ",\"title\":null,\"content\":null", ",\"title\":\" \",\"content\":\"\"").forEach { fields ->
|
||||||
|
assertEquals(error("Please enter content to update."), putNotice("""{"id":$id$fields}"""))
|
||||||
|
}
|
||||||
|
assertEquals(error("Invalid request."), putNotice("""{"id":0,"publishAt":null}"""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 JWT 보안 체인은 PUT·DELETE도 ADMIN 전용이며 기존 soft delete 응답을 유지한다")
|
||||||
|
fun shouldPreserveAdminOnlyUpdateAndDelete() {
|
||||||
|
val before = scheduledNotice()
|
||||||
|
val id = requireNotNull(before.id)
|
||||||
|
val json = """{"id":$id,"title":"반영 금지"}"""
|
||||||
|
val userToken = tokenFor(MemberRole.USER)
|
||||||
|
listOf(HttpMethod.PUT, HttpMethod.DELETE).forEach { method ->
|
||||||
|
val path = if (method == HttpMethod.PUT) "/notice" else "/notice/$id"
|
||||||
|
val anonymous = restTemplate.exchange(path, method, request(json, null), String::class.java)
|
||||||
|
assertEquals(HttpStatus.UNAUTHORIZED, anonymous.statusCode)
|
||||||
|
val forbidden = restTemplate.exchange(path, method, request(json, userToken), String::class.java)
|
||||||
|
assertEquals(HttpStatus.OK, forbidden.statusCode)
|
||||||
|
assertEquals(error("You do not have permission."), objectMapper.readTree(forbidden.body))
|
||||||
|
}
|
||||||
|
assertEquals(before, reload(id))
|
||||||
|
val response = restTemplate.exchange("/notice/$id", HttpMethod.DELETE, request("", adminToken), String::class.java)
|
||||||
|
println("HTTP DELETE /notice/$id -> ${response.statusCodeValue} ${response.body}")
|
||||||
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
|
assertEquals(
|
||||||
|
objectMapper.readTree("""{"success":true,"message":"삭제되었습니다.","data":{},"errorProperty":null}"""),
|
||||||
|
objectMapper.readTree(response.body)
|
||||||
|
)
|
||||||
|
val deleted = reload(id)
|
||||||
|
assertEquals(false, deleted.isActive)
|
||||||
|
assertEquals(before.publishAt, deleted.publishAt)
|
||||||
|
assertEquals(before.createdAt, deleted.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["anonymous", "USER", "ADMIN"])
|
||||||
|
@DisplayName("관리자를 포함한 모든 공개 경로는 경계 전 예약을 제외한다")
|
||||||
|
fun shouldExcludeScheduledNoticeBeforeBoundaryForAdminOnPublicRoutes(role: String) {
|
||||||
|
val id = saveNotice("공개", "2026-10-29T15:00:00")
|
||||||
|
repeat(5) { saveNotice("미공개", "2026-10-29T16:00:00", "2026-11-01T00:00:00") }
|
||||||
|
saveNotice("비활성", "2026-10-29T17:00:00", isActive = false)
|
||||||
|
val token = if (role == "anonymous") null else tokenFor(MemberRole.valueOf(role))
|
||||||
|
val item = """{"id":$id,"title":"공개","content":"본문","date":"2026-10-30"}"""
|
||||||
|
|
||||||
|
assertEquals(envelope(item), getNotice("/notice/latest", token))
|
||||||
|
assertEquals(envelope("[$item]"), getNotice("/api/v2/notices/latest", token))
|
||||||
|
if (token != null) {
|
||||||
|
assertEquals(
|
||||||
|
envelope("""{"totalCount":1,"noticeList":[$item]}"""),
|
||||||
|
getNotice("/notice?page=0&size=2&timezone=UTC", token)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"2026-10-31T23:59:59.999999999, false",
|
||||||
|
"2026-11-01T00:00:00, true",
|
||||||
|
"2026-11-01T00:00:00.000000001, true",
|
||||||
|
"2026-11-01T00:00:00.999999999, true",
|
||||||
|
"2026-11-02T00:00:00, true"
|
||||||
|
)
|
||||||
|
@DisplayName("원본 나노초 now로 공개 전·정각·후 경계를 판정하고 예약 공개 날짜를 쓴다")
|
||||||
|
fun shouldIncludeNoticeAtExactPublishBoundary(now: String, visible: Boolean) {
|
||||||
|
val id = saveNotice("예약", "2026-10-29T14:59:59", "2026-11-01T00:00:00")
|
||||||
|
saveNotice("비활성 예약", "2026-10-29T14:59:59", "2026-11-01T00:00:00", false)
|
||||||
|
noticeTime.set(LocalDateTime.parse(now))
|
||||||
|
val item = """{"id":$id,"title":"예약","content":"본문","date":"2026-11-01"}"""
|
||||||
|
val items = if (visible) "[$item]" else "[]"
|
||||||
|
|
||||||
|
assertEquals(envelope(if (visible) item else "null"), getNotice("/notice/latest", null))
|
||||||
|
assertEquals(envelope(items), getNotice("/api/v2/notices/latest", null))
|
||||||
|
assertEquals(
|
||||||
|
envelope("""{"totalCount":${if (visible) 1 else 0},"noticeList":$items}"""),
|
||||||
|
getNotice("/notice?page=0&size=20&timezone=UTC")
|
||||||
|
)
|
||||||
|
assertEquals(true, reload(id).isScheduled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("공개 목록과 v2 latest는 미공개 5건을 먼저 제거하고 정렬·총 건수·페이지 크기를 유지한다")
|
||||||
|
fun shouldFilterAndOrderBeforePublicPaginationAndLatestLimit() {
|
||||||
|
val newest = saveNotice("최신", "2026-10-29T14:00:00")
|
||||||
|
val oldest = saveNotice("이전", "2026-10-26T00:00:00")
|
||||||
|
val middle = saveNotice("중간", "2026-10-28T00:00:00")
|
||||||
|
val second = saveNotice("예약 공개", "2026-10-25T00:00:00", "2026-10-29T00:00:00")
|
||||||
|
repeat(5) { saveNotice("미공개", "2026-10-29T15:00:00", "2026-11-01T00:00:00") }
|
||||||
|
val newestItem = """{"id":$newest,"title":"최신","content":"본문","date":"2026-10-29"}"""
|
||||||
|
val secondItem = """{"id":$second,"title":"예약 공개","content":"본문","date":"2026-10-29"}"""
|
||||||
|
val middleItem = """{"id":$middle,"title":"중간","content":"본문","date":"2026-10-28"}"""
|
||||||
|
val oldestItem = """{"id":$oldest,"title":"이전","content":"본문","date":"2026-10-26"}"""
|
||||||
|
val user = tokenFor(MemberRole.USER)
|
||||||
|
|
||||||
|
assertEquals(envelope("[$newestItem,$secondItem,$middleItem]"), getNotice("/api/v2/notices/latest", null))
|
||||||
|
assertEquals(envelope(newestItem), getNotice("/notice/latest"))
|
||||||
|
assertEquals(
|
||||||
|
envelope("""{"totalCount":4,"noticeList":[$newestItem,$secondItem,$middleItem]}"""),
|
||||||
|
getNotice("/notice?page=0&size=3&timezone=Asia/Seoul", user)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
envelope("""{"totalCount":4,"noticeList":[$oldestItem]}"""),
|
||||||
|
getNotice("/notice?page=1&size=3&timezone=Asia/Seoul", user)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
envelope("""{"totalCount":4,"noticeList":[]}"""),
|
||||||
|
getNotice("/notice?page=2&size=3&timezone=Asia/Seoul", user)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("KST 자정을 넘긴 즉시 전환은 전환 시각 순서와 원래 등록 날짜를 분리한다")
|
||||||
|
fun shouldUseRegistrationDateButConversionTimeForImmediateScheduleConversion() {
|
||||||
|
val converted = saveNotice("전환", "2026-10-31T14:50:00", "2026-11-02T00:00:00")
|
||||||
|
val competitor = saveNotice("경쟁", "2026-10-31T15:05:00")
|
||||||
|
noticeTime.set(LocalDateTime.of(2026, 10, 31, 15, 10, 0, 999999999))
|
||||||
|
assertUpdated(putNotice("""{"id":$converted,"publishAt":null}"""))
|
||||||
|
val convertedItem = """{"id":$converted,"title":"전환","content":"본문","date":"2026-10-31"}"""
|
||||||
|
val competitorItem = """{"id":$competitor,"title":"경쟁","content":"본문","date":"2026-11-01"}"""
|
||||||
|
|
||||||
|
assertEquals(envelope(convertedItem), getNotice("/notice/latest"))
|
||||||
|
assertEquals(envelope("[$convertedItem,$competitorItem]"), getNotice("/api/v2/notices/latest", null))
|
||||||
|
assertEquals(
|
||||||
|
envelope("""{"totalCount":2,"noticeList":[$convertedItem,$competitorItem]}"""),
|
||||||
|
getNotice("/notice?page=0&size=20&timezone=UTC")
|
||||||
|
)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 10, 31, 15, 10, 0), reload(converted).publishAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 목록은 미래 활성 예약도 포함하고 공개 시각·id 역순의 0-based 페이지와 전체 DTO를 반환한다")
|
||||||
|
fun shouldIncludeActiveFutureNoticesInAdminPages() {
|
||||||
|
val immediate = saveNotice("즉시", "2026-10-29T14:59:58.123456")
|
||||||
|
val first = saveNotice("예약1", "2026-10-28T14:59:57.654321", "2026-11-01T00:00:13")
|
||||||
|
val second = saveNotice("예약2", "2026-10-27T14:59:56", "2026-11-01T00:00:13")
|
||||||
|
saveNotice("비활성", "2026-10-29T00:00:00", "2026-11-02T00:00:00", false)
|
||||||
|
val firstItem = """{"id":$first,"title":"예약1","content":"본문","isActive":true,"isScheduled":true,
|
||||||
|
"publishAt":"2026-11-01 09:00:13","createdAt":"2026-10-28 23:59:57"}"""
|
||||||
|
val secondItem = """{"id":$second,"title":"예약2","content":"본문","isActive":true,"isScheduled":true,
|
||||||
|
"publishAt":"2026-11-01 09:00:13","createdAt":"2026-10-27 23:59:56"}"""
|
||||||
|
val immediateItem = """{"id":$immediate,"title":"즉시","content":"본문","isActive":true,"isScheduled":false,
|
||||||
|
"publishAt":null,"createdAt":"2026-10-29 23:59:58"}"""
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
envelope("""{"totalCount":3,"noticeList":[$secondItem,$firstItem]}"""),
|
||||||
|
getNotice("/api/v2/admin/notices?page=0&size=2")
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
envelope("""{"totalCount":3,"noticeList":[$immediateItem]}"""),
|
||||||
|
getNotice("/api/v2/admin/notices?page=1&size=2")
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
envelope("""{"totalCount":3,"noticeList":[]}"""),
|
||||||
|
getNotice("/api/v2/admin/notices?page=2&size=2")
|
||||||
|
)
|
||||||
|
assertEquals(LocalDateTime.parse("2026-10-29T14:59:58.123456"), reload(immediate).createdAt)
|
||||||
|
noticeTime.set(LocalDateTime.of(2026, 11, 2, 0, 0, 0))
|
||||||
|
assertEquals(true, getNotice("/api/v2/admin/notices")["data"]["noticeList"][0]["isScheduled"].asBoolean())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 목록은 빈 목록을 유지하며 익명 401·비관리자 기존 권한 오류·ADMIN 200을 보장한다")
|
||||||
|
fun shouldRestrictAdminListWithoutExpandingAnonymousRoutes() {
|
||||||
|
val path = "/api/v2/admin/notices?page=0&size=20"
|
||||||
|
listOf(path, "/notice?page=0&size=20&timezone=UTC").forEach { protectedPath ->
|
||||||
|
val anonymous = restTemplate.exchange(protectedPath, HttpMethod.GET, request("", null), String::class.java)
|
||||||
|
println("HTTP anonymous GET $protectedPath -> ${anonymous.statusCodeValue} ${anonymous.body}")
|
||||||
|
assertEquals(HttpStatus.UNAUTHORIZED, anonymous.statusCode)
|
||||||
|
}
|
||||||
|
assertEquals(error("You do not have permission."), getNotice(path, tokenFor(MemberRole.USER)))
|
||||||
|
assertEquals(envelope("""{"totalCount":0,"noticeList":[]}"""), getNotice(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveNotice(
|
||||||
|
title: String,
|
||||||
|
createdAt: String,
|
||||||
|
publishAt: String? = null,
|
||||||
|
isActive: Boolean = true
|
||||||
|
): Long {
|
||||||
|
val id = requireNotNull(
|
||||||
|
repository.saveAndFlush(
|
||||||
|
ServiceNotice(title, "본문", isActive, publishAt?.let(LocalDateTime::parse), publishAt != null)
|
||||||
|
).id
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE service_notice SET created_at = ? WHERE id = ?",
|
||||||
|
Timestamp.valueOf(LocalDateTime.parse(createdAt)),
|
||||||
|
id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getNotice(path: String, token: String? = adminToken): JsonNode {
|
||||||
|
val response = restTemplate.exchange(path, HttpMethod.GET, request("", token), String::class.java)
|
||||||
|
println(
|
||||||
|
"HTTP ${if (token == null) "anonymous" else "authenticated"} GET $path -> " +
|
||||||
|
"${response.statusCodeValue} ${response.body}"
|
||||||
|
)
|
||||||
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
|
return objectMapper.readTree(response.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun envelope(data: String): JsonNode = objectMapper.readTree(
|
||||||
|
"""{"success":true,"message":null,"data":$data,"errorProperty":null}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun scheduledNotice(): ServiceNotice {
|
||||||
|
val notice = repository.saveAndFlush(
|
||||||
|
ServiceNotice("예약 제목", "예약 본문", publishAt = LocalDateTime.of(2026, 11, 1, 0, 0, 0), isScheduled = true)
|
||||||
|
)
|
||||||
|
val id = requireNotNull(notice.id)
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE service_notice SET created_at = ? WHERE id = ?",
|
||||||
|
Timestamp.valueOf("2026-10-29 14:59:59.123456"),
|
||||||
|
id
|
||||||
|
)
|
||||||
|
return reload(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reload(id: Long): ServiceNotice {
|
||||||
|
return requireNotNull(
|
||||||
|
TransactionTemplate(transactionManager).execute {
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
repository.findById(id).orElseThrow().also {
|
||||||
|
println(
|
||||||
|
"DB reload id=$id title=${it.title} active=${it.isActive} scheduled=${it.isScheduled} " +
|
||||||
|
"publishAt=${it.publishAt} createdAt=${it.createdAt}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun putNotice(json: String, token: String = adminToken): JsonNode {
|
||||||
|
val response = restTemplate.exchange("/notice", HttpMethod.PUT, request(json, token), String::class.java)
|
||||||
|
println("HTTP PUT /notice input=$json -> ${response.statusCodeValue} ${response.body}")
|
||||||
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
|
return objectMapper.readTree(response.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertUpdated(body: JsonNode) {
|
||||||
|
assertEquals(
|
||||||
|
objectMapper.readTree("""{"success":true,"message":"수정되었습니다.","data":{},"errorProperty":null}"""),
|
||||||
|
body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postNotice(json: String, token: String = adminToken): JsonNode {
|
||||||
|
val response = restTemplate.postForEntity("/notice", request(json, token), String::class.java)
|
||||||
|
println("HTTP POST /notice input=$json -> ${response.statusCodeValue} ${response.body}")
|
||||||
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
|
return objectMapper.readTree(response.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(json: String, token: String?): HttpEntity<String> = HttpEntity(
|
||||||
|
json,
|
||||||
|
HttpHeaders().apply {
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
set(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
|
if (token != null) setBearerAuth(token)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun error(message: String): JsonNode = objectMapper.readTree(
|
||||||
|
"""{"success":false,"message":"$message","data":null,"errorProperty":null}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun assertCreated(body: JsonNode, id: Long) {
|
||||||
|
assertEquals(
|
||||||
|
objectMapper.readTree("""{"success":true,"message":"등록되었습니다.","data":$id,"errorProperty":null}"""),
|
||||||
|
body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
class TimeConfiguration {
|
||||||
|
@Bean
|
||||||
|
fun noticeTime(): AtomicReference<LocalDateTime> = AtomicReference(LocalDateTime.of(2026, 10, 30, 0, 0, 0))
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun noticeNowProvider(noticeTime: AtomicReference<LocalDateTime>): () -> LocalDateTime = { noticeTime.get() }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val ADMIN_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-12
@@ -13,7 +13,10 @@ import org.junit.jupiter.params.ParameterizedTest
|
|||||||
import org.junit.jupiter.params.provider.CsvSource
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.context.SpringBootTest
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration
|
||||||
import org.springframework.boot.test.web.client.TestRestTemplate
|
import org.springframework.boot.test.web.client.TestRestTemplate
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.http.HttpEntity
|
import org.springframework.http.HttpEntity
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
@@ -22,6 +25,7 @@ import org.springframework.test.annotation.DirtiesContext
|
|||||||
import org.springframework.test.context.ContextConfiguration
|
import org.springframework.test.context.ContextConfiguration
|
||||||
import java.sql.Timestamp
|
import java.sql.Timestamp
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
@SpringBootTest(
|
@SpringBootTest(
|
||||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||||
@@ -31,12 +35,14 @@ import java.time.LocalDateTime
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@Import(LatestNoticeControllerIntegrationTest.TimeConfiguration::class)
|
||||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
||||||
private val restTemplate: TestRestTemplate,
|
private val restTemplate: TestRestTemplate,
|
||||||
private val repository: ServiceServiceNoticeRepository,
|
private val repository: ServiceServiceNoticeRepository,
|
||||||
private val jdbcTemplate: JdbcTemplate,
|
private val jdbcTemplate: JdbcTemplate,
|
||||||
private val objectMapper: ObjectMapper
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val nowCalls: AtomicInteger
|
||||||
) {
|
) {
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
@@ -47,6 +53,7 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
|||||||
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
|
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
|
||||||
)
|
)
|
||||||
repository.deleteAllInBatch()
|
repository.deleteAllInBatch()
|
||||||
|
nowCalls.set(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
@@ -55,15 +62,15 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("익명 요청은 작성 시각과 무관하게 활성 공지 id 역순 최대 3개와 전체 필드를 반환한다")
|
@DisplayName("익명 요청은 유효 공개 시각 역순 최대 3개와 전체 필드를 반환한다")
|
||||||
fun shouldReturnLatestThreeActiveNoticesByIdWithUnchangedFields() {
|
fun shouldReturnLatestThreeActiveNoticesByPublicationWithUnchangedFields() {
|
||||||
saveNotice("제외될 활성 공지", "오래된 id", "2026-10-30T12:00:00")
|
saveNotice("제외될 활성 공지", "오래된 공개 시각", "2026-10-26T12:00:00")
|
||||||
val second = saveNotice("두 번째 공지", "본문 2", "2026-10-29T12:00:00")
|
val second = saveNotice("두 번째 공지", "본문 2", "2026-10-29T12:00:00")
|
||||||
val third = saveNotice("세 번째 공지", "본문 3", "2026-10-28T12:00:00")
|
val third = saveNotice("세 번째 공지", "본문 3", "2026-10-28T12:00:00")
|
||||||
val fourth = saveNotice(
|
val fourth = saveNotice(
|
||||||
"최근 서비스 공지",
|
"최근 서비스 공지",
|
||||||
"서비스 공지\n두 번째 줄: <b>전체 본문</b> & \"인용\"",
|
"서비스 공지\n두 번째 줄: <b>전체 본문</b> & \"인용\"",
|
||||||
"2026-10-27T15:00:00"
|
"2026-10-29T15:00:00"
|
||||||
)
|
)
|
||||||
saveNotice("더 큰 비활성 id", "공개하면 안 되는 본문", "2026-10-31T12:00:00", false)
|
saveNotice("더 큰 비활성 id", "공개하면 안 되는 본문", "2026-10-31T12:00:00", false)
|
||||||
|
|
||||||
@@ -72,9 +79,9 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
|||||||
"""
|
"""
|
||||||
{"success":true,"message":null,"data":[
|
{"success":true,"message":null,"data":[
|
||||||
{"id":$fourth,"title":"최근 서비스 공지",
|
{"id":$fourth,"title":"최근 서비스 공지",
|
||||||
"content":"서비스 공지\n두 번째 줄: <b>전체 본문</b> & \"인용\"","date":"2026-10-28"},
|
"content":"서비스 공지\n두 번째 줄: <b>전체 본문</b> & \"인용\"","date":"2026-10-30"},
|
||||||
{"id":$third,"title":"세 번째 공지","content":"본문 3","date":"2026-10-28"},
|
{"id":$second,"title":"두 번째 공지","content":"본문 2","date":"2026-10-29"},
|
||||||
{"id":$second,"title":"두 번째 공지","content":"본문 2","date":"2026-10-29"}
|
{"id":$third,"title":"세 번째 공지","content":"본문 3","date":"2026-10-28"}
|
||||||
],"errorProperty":null}
|
],"errorProperty":null}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
@@ -121,7 +128,7 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("활성 공지 2개는 중복 없이 id 역순으로 반환한다")
|
@DisplayName("활성 공지 2개는 중복 없이 공개 시각 역순으로 반환한다")
|
||||||
fun shouldReturnTwoNoticesWithoutPadding() {
|
fun shouldReturnTwoNoticesWithoutPadding() {
|
||||||
val first = saveNotice("첫 공지", "첫 본문", "2026-10-28T12:00:00")
|
val first = saveNotice("첫 공지", "첫 본문", "2026-10-28T12:00:00")
|
||||||
val second = saveNotice("다음 공지", "다음 본문", "2026-10-27T12:00:00")
|
val second = saveNotice("다음 공지", "다음 본문", "2026-10-27T12:00:00")
|
||||||
@@ -130,8 +137,8 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
|||||||
"/api/v2/notices/latest",
|
"/api/v2/notices/latest",
|
||||||
"""
|
"""
|
||||||
{"success":true,"message":null,"data":[
|
{"success":true,"message":null,"data":[
|
||||||
{"id":$second,"title":"다음 공지","content":"다음 본문","date":"2026-10-27"},
|
{"id":$first,"title":"첫 공지","content":"첫 본문","date":"2026-10-28"},
|
||||||
{"id":$first,"title":"첫 공지","content":"첫 본문","date":"2026-10-28"}
|
{"id":$second,"title":"다음 공지","content":"다음 본문","date":"2026-10-27"}
|
||||||
],"errorProperty":null}
|
],"errorProperty":null}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
@@ -152,7 +159,7 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("기존 v1 최근 공지는 배열이 아닌 최신 활성 공지 단일 객체를 유지한다")
|
@DisplayName("기존 v1 최근 공지는 배열이 아닌 최신 활성 공지 단일 객체를 유지한다")
|
||||||
fun shouldKeepLegacyLatestNoticeAsAnObject() {
|
fun shouldKeepLegacyLatestNoticeAsAnObject() {
|
||||||
saveNotice("이전 공지", "이전 본문", "2026-10-29T12:00:00")
|
saveNotice("이전 공지", "이전 본문", "2026-10-26T12:00:00")
|
||||||
val latest = saveNotice("기존 최근 공지", "기존 전체 본문", "2026-10-27T15:00:00")
|
val latest = saveNotice("기존 최근 공지", "기존 전체 본문", "2026-10-27T15:00:00")
|
||||||
saveNotice("비활성", "미공개 본문", "2026-10-30T12:00:00", false)
|
saveNotice("비활성", "미공개 본문", "2026-10-30T12:00:00", false)
|
||||||
|
|
||||||
@@ -172,6 +179,15 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
|||||||
assertGet("/notice/latest", """{"success":true,"message":null,"data":null,"errorProperty":null}""")
|
assertGet("/notice/latest", """{"success":true,"message":null,"data":null,"errorProperty":null}""")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource("/notice/latest", "/api/v2/notices/latest")
|
||||||
|
@DisplayName("각 공개 최신 조회는 서비스에서 UTC now를 정확히 한 번 캡처한다")
|
||||||
|
fun shouldCaptureNowOnceForEachLatestService(path: String) {
|
||||||
|
val response = restTemplate.getForEntity(path, String::class.java)
|
||||||
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
|
assertEquals(1, nowCalls.get())
|
||||||
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@CsvSource("POST, /api/v2/notices/latest", "GET, /api/v2/notices/private")
|
@CsvSource("POST, /api/v2/notices/latest", "GET, /api/v2/notices/private")
|
||||||
@DisplayName("익명 POST 및 인접 보호 경로는 공개하지 않는다")
|
@DisplayName("익명 POST 및 인접 보호 경로는 공개하지 않는다")
|
||||||
@@ -202,4 +218,16 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
return noticeId
|
return noticeId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
class TimeConfiguration {
|
||||||
|
@Bean
|
||||||
|
fun nowCalls(): AtomicInteger = AtomicInteger()
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun noticeNowProvider(nowCalls: AtomicInteger): () -> LocalDateTime = {
|
||||||
|
nowCalls.incrementAndGet()
|
||||||
|
LocalDateTime.of(2026, 10, 30, 0, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user