From 58f32863801c7c79c5d3625c8b95000eddeac48a Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 28 Oct 2026 16:19:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(notice):=20v2=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20=EA=B3=B5=EC=A7=80=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/configs/SecurityConfig.kt | 1 + .../adapter/in/web/LatestNoticeController.kt | 14 ++ .../notice/application/LatestNoticeService.kt | 13 ++ .../LatestNoticeControllerIntegrationTest.kt | 205 ++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/adapter/in/web/LatestNoticeController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index f6947191..bd55f234 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -191,6 +191,7 @@ class SecurityConfig( .antMatchers(HttpMethod.GET, "/live/recommend").permitAll() .antMatchers("/ad-tracking/app-launch").permitAll() .antMatchers(HttpMethod.GET, "/notice/latest").permitAll() + .antMatchers(HttpMethod.GET, "/api/v2/notices/latest").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/adapter/in/web/LatestNoticeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/adapter/in/web/LatestNoticeController.kt new file mode 100644 index 00000000..ae6bfed8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/adapter/in/web/LatestNoticeController.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.v2.api.notice.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.api.notice.application.LatestNoticeService +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/notices") +class LatestNoticeController(private val service: LatestNoticeService) { + @GetMapping("/latest") + fun getLatestNotices() = ApiResponse.ok(service.getLatestNotices()) +} 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 new file mode 100644 index 00000000..cda88bd2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.v2.api.notice.application + +import kr.co.vividnext.sodalive.notice.NoticeItem +import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class LatestNoticeService(private val repository: ServiceServiceNoticeRepository) { + fun getLatestNotices(): List = repository.getNoticeList(PageRequest.of(0, 3)) +} 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 new file mode 100644 index 00000000..3c0260a3 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt @@ -0,0 +1,205 @@ +package kr.co.vividnext.sodalive.v2.api.notice + +import com.fasterxml.jackson.databind.ObjectMapper +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.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.web.client.TestRestTemplate +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import java.sql.Timestamp +import java.time.LocalDateTime + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.datasource.url=jdbc:h2:mem:latest-notice-controller-integration;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class LatestNoticeControllerIntegrationTest @Autowired constructor( + private val restTemplate: TestRestTemplate, + private val repository: ServiceServiceNoticeRepository, + private val jdbcTemplate: JdbcTemplate, + private val objectMapper: ObjectMapper +) { + @BeforeEach + fun setUp() { + 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'" + ) + repository.deleteAllInBatch() + } + + @AfterEach + fun tearDown() { + repository.deleteAllInBatch() + } + + @Test + @DisplayName("익명 요청은 작성 시각과 무관하게 활성 공지 id 역순 최대 3개와 전체 필드를 반환한다") + fun shouldReturnLatestThreeActiveNoticesByIdWithUnchangedFields() { + saveNotice("제외될 활성 공지", "오래된 id", "2026-10-30T12: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" + ) + saveNotice("더 큰 비활성 id", "공개하면 안 되는 본문", "2026-10-31T12:00:00", false) + + assertGet( + "/api/v2/notices/latest", + """ + {"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"} + ],"errorProperty":null} + """.trimIndent() + ) + } + + @ParameterizedTest + @CsvSource("2026-10-27T14:59:59, 2026-10-27", "2026-10-27T15:00:00, 2026-10-28") + @DisplayName("UTC 시각은 기존 KST 자정 경계에 맞춰 날짜 문자열로 반환한다") + fun shouldPreserveExistingDateAtKstMidnight(createdAt: String, expectedDate: String) { + val noticeId = saveNotice("날짜 경계", "날짜 본문", createdAt) + + assertGet( + "/api/v2/notices/latest", + """ + {"success":true,"message":null,"data":[ + {"id":$noticeId,"title":"날짜 경계","content":"날짜 본문","date":"$expectedDate"} + ],"errorProperty":null} + """.trimIndent() + ) + } + + @Test + @DisplayName("공지가 없으면 null 대신 빈 배열을 반환한다") + fun shouldReturnEmptyArrayWhenThereAreNoNotices() { + assertGet( + "/api/v2/notices/latest", + """{"success":true,"message":null,"data":[],"errorProperty":null}""" + ) + } + + @Test + @DisplayName("활성 공지 1개는 원래 필드 그대로 한 항목 배열로 반환한다") + fun shouldReturnOneNoticeWithoutPadding() { + val noticeId = saveNotice("단일 공지", "단일 본문", "2026-10-27T12:00:00") + + assertGet( + "/api/v2/notices/latest", + """ + {"success":true,"message":null,"data":[ + {"id":$noticeId,"title":"단일 공지","content":"단일 본문","date":"2026-10-27"} + ],"errorProperty":null} + """.trimIndent() + ) + } + + @Test + @DisplayName("활성 공지 2개는 중복 없이 id 역순으로 반환한다") + fun shouldReturnTwoNoticesWithoutPadding() { + val first = saveNotice("첫 공지", "첫 본문", "2026-10-28T12:00:00") + val second = saveNotice("다음 공지", "다음 본문", "2026-10-27T12:00:00") + + assertGet( + "/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"} + ],"errorProperty":null} + """.trimIndent() + ) + } + + @Test + @DisplayName("비활성 공지만 있으면 빈 배열을 반환한다") + fun shouldReturnEmptyArrayWhenAllNoticesAreInactive() { + saveNotice("비활성 1", "본문 1", "2026-10-27T12:00:00", false) + saveNotice("비활성 2", "본문 2", "2026-10-28T12:00:00", false) + + assertGet( + "/api/v2/notices/latest", + """{"success":true,"message":null,"data":[],"errorProperty":null}""" + ) + } + + @Test + @DisplayName("기존 v1 최근 공지는 배열이 아닌 최신 활성 공지 단일 객체를 유지한다") + fun shouldKeepLegacyLatestNoticeAsAnObject() { + saveNotice("이전 공지", "이전 본문", "2026-10-29T12:00:00") + val latest = saveNotice("기존 최근 공지", "기존 전체 본문", "2026-10-27T15:00:00") + saveNotice("비활성", "미공개 본문", "2026-10-30T12:00:00", false) + + assertGet( + "/notice/latest", + """ + {"success":true,"message":null,"data": + {"id":$latest,"title":"기존 최근 공지","content":"기존 전체 본문","date":"2026-10-28"}, + "errorProperty":null} + """.trimIndent() + ) + } + + @Test + @DisplayName("기존 v1 최근 공지는 데이터가 없으면 null을 유지한다") + fun shouldKeepLegacyLatestNoticeNullWhenEmpty() { + assertGet("/notice/latest", """{"success":true,"message":null,"data":null,"errorProperty":null}""") + } + + @ParameterizedTest + @CsvSource("POST, /api/v2/notices/latest", "GET, /api/v2/notices/private") + @DisplayName("익명 POST 및 인접 보호 경로는 공개하지 않는다") + fun shouldRejectAnonymousRequestsOutsideTheExactGetRoute(method: HttpMethod, path: String) { + val response = restTemplate.exchange(path, method, HttpEntity.EMPTY, String::class.java) + + println("HTTP $method $path -> ${response.statusCodeValue} ${response.body}") + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode) + } + + private fun assertGet(path: String, expectedJson: String) { + val response = restTemplate.getForEntity(path, String::class.java) + + println("HTTP GET $path -> ${response.statusCodeValue} ${response.body}") + assertEquals(HttpStatus.OK, response.statusCode) + assertEquals(objectMapper.readTree(expectedJson), objectMapper.readTree(response.body)) + } + + private fun saveNotice(title: String, content: String, createdAt: String, isActive: Boolean = true): Long { + val noticeId = requireNotNull(repository.saveAndFlush(ServiceNotice(title, content, isActive)).id) + assertEquals( + 1, + jdbcTemplate.update( + "UPDATE service_notice SET created_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDateTime.parse(createdAt)), + noticeId + ) + ) + return noticeId + } +}