feat(notice): 예약 관리와 공개 시점 기반 조회를 구현한다
This commit is contained in:
+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("%h", "hh")
|
||||
.replace("%i", "mm")
|
||||
.replace("%s", "ss")
|
||||
.replace("%p", "a")
|
||||
|
||||
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.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.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
@@ -22,6 +25,7 @@ import org.springframework.test.annotation.DirtiesContext
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import java.sql.Timestamp
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@SpringBootTest(
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
@@ -31,12 +35,14 @@ import java.time.LocalDateTime
|
||||
]
|
||||
)
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
@Import(LatestNoticeControllerIntegrationTest.TimeConfiguration::class)
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||
class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
||||
private val restTemplate: TestRestTemplate,
|
||||
private val repository: ServiceServiceNoticeRepository,
|
||||
private val jdbcTemplate: JdbcTemplate,
|
||||
private val objectMapper: ObjectMapper
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val nowCalls: AtomicInteger
|
||||
) {
|
||||
@BeforeEach
|
||||
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'"
|
||||
)
|
||||
repository.deleteAllInBatch()
|
||||
nowCalls.set(0)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -55,15 +62,15 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("익명 요청은 작성 시각과 무관하게 활성 공지 id 역순 최대 3개와 전체 필드를 반환한다")
|
||||
fun shouldReturnLatestThreeActiveNoticesByIdWithUnchangedFields() {
|
||||
saveNotice("제외될 활성 공지", "오래된 id", "2026-10-30T12:00:00")
|
||||
@DisplayName("익명 요청은 유효 공개 시각 역순 최대 3개와 전체 필드를 반환한다")
|
||||
fun shouldReturnLatestThreeActiveNoticesByPublicationWithUnchangedFields() {
|
||||
saveNotice("제외될 활성 공지", "오래된 공개 시각", "2026-10-26T12:00:00")
|
||||
val second = saveNotice("두 번째 공지", "본문 2", "2026-10-29T12:00:00")
|
||||
val third = saveNotice("세 번째 공지", "본문 3", "2026-10-28T12:00:00")
|
||||
val fourth = saveNotice(
|
||||
"최근 서비스 공지",
|
||||
"서비스 공지\n두 번째 줄: <b>전체 본문</b> & \"인용\"",
|
||||
"2026-10-27T15:00:00"
|
||||
"2026-10-29T15:00:00"
|
||||
)
|
||||
saveNotice("더 큰 비활성 id", "공개하면 안 되는 본문", "2026-10-31T12:00:00", false)
|
||||
|
||||
@@ -72,9 +79,9 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
||||
"""
|
||||
{"success":true,"message":null,"data":[
|
||||
{"id":$fourth,"title":"최근 서비스 공지",
|
||||
"content":"서비스 공지\n두 번째 줄: <b>전체 본문</b> & \"인용\"","date":"2026-10-28"},
|
||||
{"id":$third,"title":"세 번째 공지","content":"본문 3","date":"2026-10-28"},
|
||||
{"id":$second,"title":"두 번째 공지","content":"본문 2","date":"2026-10-29"}
|
||||
"content":"서비스 공지\n두 번째 줄: <b>전체 본문</b> & \"인용\"","date":"2026-10-30"},
|
||||
{"id":$second,"title":"두 번째 공지","content":"본문 2","date":"2026-10-29"},
|
||||
{"id":$third,"title":"세 번째 공지","content":"본문 3","date":"2026-10-28"}
|
||||
],"errorProperty":null}
|
||||
""".trimIndent()
|
||||
)
|
||||
@@ -121,7 +128,7 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("활성 공지 2개는 중복 없이 id 역순으로 반환한다")
|
||||
@DisplayName("활성 공지 2개는 중복 없이 공개 시각 역순으로 반환한다")
|
||||
fun shouldReturnTwoNoticesWithoutPadding() {
|
||||
val first = saveNotice("첫 공지", "첫 본문", "2026-10-28T12:00:00")
|
||||
val second = saveNotice("다음 공지", "다음 본문", "2026-10-27T12:00:00")
|
||||
@@ -130,8 +137,8 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
||||
"/api/v2/notices/latest",
|
||||
"""
|
||||
{"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}
|
||||
""".trimIndent()
|
||||
)
|
||||
@@ -152,7 +159,7 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
||||
@Test
|
||||
@DisplayName("기존 v1 최근 공지는 배열이 아닌 최신 활성 공지 단일 객체를 유지한다")
|
||||
fun shouldKeepLegacyLatestNoticeAsAnObject() {
|
||||
saveNotice("이전 공지", "이전 본문", "2026-10-29T12:00:00")
|
||||
saveNotice("이전 공지", "이전 본문", "2026-10-26T12:00:00")
|
||||
val latest = saveNotice("기존 최근 공지", "기존 전체 본문", "2026-10-27T15:00:00")
|
||||
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}""")
|
||||
}
|
||||
|
||||
@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
|
||||
@CsvSource("POST, /api/v2/notices/latest", "GET, /api/v2/notices/private")
|
||||
@DisplayName("익명 POST 및 인접 보호 경로는 공개하지 않는다")
|
||||
@@ -202,4 +218,16 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor(
|
||||
)
|
||||
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