feat(notice): 예약 관리와 공개 시점 기반 조회를 구현한다

This commit is contained in:
2026-10-30 22:50:58 +09:00
parent 4f4c842ccf
commit 394949ab6e
16 changed files with 1544 additions and 69 deletions
@@ -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
) )
@@ -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))
}
@@ -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
)
@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.v2.api.admin.notice.dto
data class AdminNoticeResponse(
val totalCount: Int,
val noticeList: List<AdminNoticeItem>
)
@@ -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)
}
} }
@@ -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) }
}
}
@@ -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))
@@ -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")
}
}
@@ -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)
}
}
} }