From 394949ab6e1ff64e84e318f9b10a1d1aa45f5175 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 30 Oct 2026 22:50:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(notice):=20=EC=98=88=EC=95=BD=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=99=80=20=EA=B3=B5=EA=B0=9C=20=EC=8B=9C=EC=A0=90=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/notice/CreateNoticeRequest.kt | 9 +- .../notice/ServiceNoticeController.kt | 32 +- .../sodalive/notice/ServiceNoticeService.kt | 65 +- .../notice/ServiceServiceNoticeRepository.kt | 105 ++- .../sodalive/notice/UpdateNoticeRequest.kt | 3 +- .../adapter/in/web/AdminNoticeController.kt | 18 + .../notice/application/AdminNoticeService.kt | 17 + .../api/admin/notice/dto/AdminNoticeItem.kt | 13 + .../admin/notice/dto/AdminNoticeResponse.kt | 6 + .../notice/application/LatestNoticeService.kt | 12 +- ...ontentMainTabHomeServiceIntegrationTest.kt | 104 +++ ...ServiceNoticeConcurrencyIntegrationTest.kt | 282 +++++++ .../ServiceNoticeQueryRepositoryTest.kt | 199 +++++ .../sodalive/support/H2MysqlDateFunctions.kt | 1 + .../AdminNoticeControllerIntegrationTest.kt | 695 ++++++++++++++++++ .../LatestNoticeControllerIntegrationTest.kt | 52 +- 16 files changed, 1544 insertions(+), 69 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/adapter/in/web/AdminNoticeController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/application/AdminNoticeService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeItem.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeResponse.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeConcurrencyIntegrationTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/CreateNoticeRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/CreateNoticeRequest.kt index 5c48a407..8c465e4e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/notice/CreateNoticeRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/CreateNoticeRequest.kt @@ -1,10 +1,13 @@ package kr.co.vividnext.sodalive.notice +import java.time.LocalDateTime + data class CreateNoticeRequest( val title: String, - val content: String + val content: String, + val publishAt: String? = null ) { - fun toEntity(): ServiceNotice { - return ServiceNotice(title, content) + fun toEntity(publishAt: LocalDateTime? = null): ServiceNotice { + return ServiceNotice(title, content, publishAt = publishAt, isScheduled = publishAt != null) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt index 299dd20e..839bb96e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt @@ -1,6 +1,9 @@ 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.SodaException import org.springframework.data.domain.Pageable import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.DeleteMapping @@ -14,20 +17,31 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/notice") -class ServiceNoticeController(private val service: ServiceNoticeService) { +class ServiceNoticeController( + private val service: ServiceNoticeService, + private val objectMapper: ObjectMapper +) { @PostMapping @PreAuthorize("hasRole('ADMIN')") - fun createNotice(@RequestBody request: CreateNoticeRequest) = ApiResponse.ok( - service.save(request), - "등록되었습니다." - ) + fun createNotice(@RequestBody fields: JsonNode): ApiResponse { + 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 @PreAuthorize("hasRole('ADMIN')") - fun updateNotice(@RequestBody request: UpdateNoticeRequest) = ApiResponse.ok( - service.update(request), - "수정되었습니다." - ) + fun updateNotice(@RequestBody fields: JsonNode): ApiResponse { + 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}") @PreAuthorize("hasRole('ADMIN')") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt index a5d873b7..31d4a4aa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt @@ -2,32 +2,52 @@ package kr.co.vividnext.sodalive.notice import kr.co.vividnext.sodalive.common.SodaException import org.springframework.data.domain.Pageable -import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service 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 @Transactional(readOnly = true) -class ServiceNoticeService(private val repository: ServiceServiceNoticeRepository) { +class ServiceNoticeService( + private val repository: ServiceServiceNoticeRepository, + private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) } +) { @Transactional fun save(request: CreateNoticeRequest): Long { if (request.title.isBlank()) throw SodaException(messageKey = "notice.error.title_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!! } @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.title.isNullOrBlank() && request.content.isNullOrBlank()) { + if (request.title.isNullOrBlank() && request.content.isNullOrBlank() && !publishAtPresent) { 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") + 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.content.isNullOrBlank()) notice.content = request.content } @@ -35,20 +55,45 @@ class ServiceNoticeService(private val repository: ServiceServiceNoticeRepositor @Transactional fun delete(id: Long) { 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") notice.isActive = false } fun getNoticeList(pageable: Pageable, timezone: String): GetNoticeResponse { - val totalCount = repository.getNoticeTotalCount() - val noticeList = repository.getNoticeList(pageable) + val now = nowProvider() + val totalCount = repository.getNoticeTotalCount(now) + val noticeList = repository.getNoticeList(pageable, now) return GetNoticeResponse(totalCount, noticeList) } 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) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt index f1261e43..9cab98d9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt @@ -1,80 +1,121 @@ 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.StringTemplate import com.querydsl.jpa.impl.JPAQueryFactory 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.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 java.time.LocalDateTime +import javax.persistence.LockModeType @Repository -interface ServiceServiceNoticeRepository : JpaRepository, ServiceNoticeQueryRepository +interface ServiceServiceNoticeRepository : JpaRepository, ServiceNoticeQueryRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select notice from ServiceNotice notice where notice.id = :id") + fun findByIdForUpdate(@Param("id") id: Long): ServiceNotice? +} interface ServiceNoticeQueryRepository { - fun getNoticeTotalCount(): Int - fun getNoticeList(pageable: Pageable): List - fun getLatestNotice(): NoticeItem? + fun getNoticeTotalCount(now: LocalDateTime): Int + fun getNoticeList(pageable: Pageable, now: LocalDateTime): List + fun getLatestNotice(now: LocalDateTime): NoticeItem? + fun getAdminNoticeTotalCount(): Int + fun getAdminNoticeList(pageable: Pageable): List } @Repository class ServiceNoticeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : ServiceNoticeQueryRepository { - override fun getNoticeTotalCount(): Int { - return queryFactory - .select(serviceNotice.id) - .from(serviceNotice) - .where(serviceNotice.isActive.isTrue) - .fetch() - .size + override fun getNoticeTotalCount(now: LocalDateTime): Int { + return requireNotNull( + queryFactory.select(serviceNotice.id.count()) + .from(serviceNotice) + .where(isPublic(now)) + .fetchOne() + ).toInt() } - override fun getNoticeList(pageable: Pageable): List { + override fun getNoticeList(pageable: Pageable, now: LocalDateTime): List { return queryFactory .select( QNoticeItem( serviceNotice.id, serviceNotice.title, 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 { + 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) .where(serviceNotice.isActive.isTrue) .offset(pageable.offset) .limit(pageable.pageSize.toLong()) - .orderBy(serviceNotice.id.desc()) + .orderBy(serviceNotice.publishAt.coalesce(serviceNotice.createdAt).desc(), serviceNotice.id.desc()) .fetch() } - override fun getLatestNotice(): NoticeItem? { - return queryFactory - .select( - QNoticeItem( - serviceNotice.id, - serviceNotice.title, - serviceNotice.content, - getFormattedDate(serviceNotice.createdAt) - ) - ) - .from(serviceNotice) - .where(serviceNotice.isActive.isTrue) - .orderBy(serviceNotice.id.desc()) - .fetchFirst() + private fun isPublic(now: LocalDateTime): BooleanExpression { + return serviceNotice.isActive.isTrue.and(serviceNotice.publishAt.isNull.or(serviceNotice.publishAt.loe(now))) } - private fun getFormattedDate(dateTimePath: DateTimePath): StringTemplate { + private fun getFormattedDate(dateTime: Expression, format: String): StringTemplate { return Expressions.stringTemplate( "DATE_FORMAT({0}, {1})", Expressions.dateTimeTemplate( LocalDateTime::class.java, "CONVERT_TZ({0},{1},{2})", - dateTimePath, + dateTime, "UTC", "Asia/Seoul" ), - "%Y-%m-%d" + format ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/UpdateNoticeRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/UpdateNoticeRequest.kt index c3da07da..f089d090 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/notice/UpdateNoticeRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/UpdateNoticeRequest.kt @@ -3,5 +3,6 @@ package kr.co.vividnext.sodalive.notice data class UpdateNoticeRequest( val id: Long, val title: String? = null, - val content: String? = null + val content: String? = null, + val publishAt: String? = null ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/adapter/in/web/AdminNoticeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/adapter/in/web/AdminNoticeController.kt new file mode 100644 index 00000000..b6699425 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/adapter/in/web/AdminNoticeController.kt @@ -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 = ApiResponse.ok(service.getNoticeList(pageable)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/application/AdminNoticeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/application/AdminNoticeService.kt new file mode 100644 index 00000000..3a591e05 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/application/AdminNoticeService.kt @@ -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) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeItem.kt new file mode 100644 index 00000000..02ad5914 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeItem.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeResponse.kt new file mode 100644 index 00000000..01ae91c1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeResponse.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.v2.api.admin.notice.dto + +data class AdminNoticeResponse( + val totalCount: Int, + val noticeList: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt index cda88bd2..35dc8aa8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt @@ -5,9 +5,17 @@ import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneOffset @Service @Transactional(readOnly = true) -class LatestNoticeService(private val repository: ServiceServiceNoticeRepository) { - fun getLatestNotices(): List = repository.getNoticeList(PageRequest.of(0, 3)) +class LatestNoticeService( + private val repository: ServiceServiceNoticeRepository, + private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) } +) { + fun getLatestNotices(): List { + val now = nowProvider() + return repository.getNoticeList(PageRequest.of(0, 3), now) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt new file mode 100644 index 00000000..14a8b3da --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt @@ -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) } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeConcurrencyIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeConcurrencyIntegrationTest.kt new file mode 100644 index 00000000..f130d2e2 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeConcurrencyIntegrationTest.kt @@ -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 +) { + @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>() + 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 { 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 = AtomicReference(LocalDateTime.of(2026, 10, 30, 0, 0, 0)) + + @Bean + fun noticeNowProvider(noticeTime: AtomicReference): () -> LocalDateTime = { noticeTime.get() } + } + + companion object { + private const val NEXT_SCHEDULE = "2026-11-02 09:00:00" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt new file mode 100644 index 00000000..eadabf72 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt @@ -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) } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt index d30246b8..2535ac39 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt @@ -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)) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt new file mode 100644 index 00000000..3a4ff2f2 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt @@ -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 +) { + private lateinit var adminToken: String + private val memberIds = mutableListOf() + + @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 = 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 = AtomicReference(LocalDateTime.of(2026, 10, 30, 0, 0, 0)) + + @Bean + fun noticeNowProvider(noticeTime: AtomicReference): () -> LocalDateTime = { noticeTime.get() } + } + + companion object { + private val ADMIN_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt index 3c0260a3..5ffad992 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt @@ -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두 번째 줄: 전체 본문 & \"인용\"", - "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두 번째 줄: 전체 본문 & \"인용\"","date":"2026-10-28"}, - {"id":$third,"title":"세 번째 공지","content":"본문 3","date":"2026-10-28"}, - {"id":$second,"title":"두 번째 공지","content":"본문 2","date":"2026-10-29"} + "content":"서비스 공지\n두 번째 줄: 전체 본문 & \"인용\"","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) + } + } }