feat(notice): v2 최근 활성 공지 조회를 추가한다
This commit is contained in:
@@ -191,6 +191,7 @@ class SecurityConfig(
|
|||||||
.antMatchers(HttpMethod.GET, "/live/recommend").permitAll()
|
.antMatchers(HttpMethod.GET, "/live/recommend").permitAll()
|
||||||
.antMatchers("/ad-tracking/app-launch").permitAll()
|
.antMatchers("/ad-tracking/app-launch").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/notice/latest").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/character/main").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
|
||||||
|
|||||||
+14
@@ -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())
|
||||||
|
}
|
||||||
+13
@@ -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<NoticeItem> = repository.getNoticeList(PageRequest.of(0, 3))
|
||||||
|
}
|
||||||
+205
@@ -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두 번째 줄: <b>전체 본문</b> & \"인용\"",
|
||||||
|
"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두 번째 줄: <b>전체 본문</b> & \"인용\"","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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user