Test #462

Merged
klaus merged 13 commits from test into main 2026-10-30 03:15:43 +00:00
4 changed files with 233 additions and 0 deletions
Showing only changes of commit 58f3286380 - Show all commits
@@ -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()
@@ -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())
}
@@ -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))
}
@@ -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
}
}